Compare commits

...
106 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
thomasandClaude 7c92f23351 Aktivitäten-Feed: empfangene und gesendete E-Mails einbeziehen
Der Aktivitäten-Feed (Startseite, /aktivitaeten, /statistik) führt Status-
änderungen und E-Mails per UNION zusammen, sortiert nach Erfassungszeit.
E-Mail-Einträge bekommen einen eigenen Farbpunkt (blau = erhalten, sky =
gesendet); ohne verknüpfte Bewerbung wird Absender bzw. Empfänger als
Kopfzeile gezeigt. Das UNION steckt in einem Subselect, weil SQLite ORDER BY
an einem Compound-SELECT keine Ergebnisspalten nach Namen aufloest. Die
Paginierung von /aktivitaeten zaehlt jetzt Statusaenderungen + E-Mails.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 15:03:20 +02:00
thomasandClaude fc1b68ebb4 Bewerbungen favorisieren (statt Jobangebote)
Stern zum Merken einzelner Bewerbungen auf der Startseiten-Liste, der
Bewerbungsdetailseite und einer neuen 'Gemerkte Bewerbungen'-Sektion ganz
oben — unabhaengig vom Status. Die bisherigen Jobangebot-Favoriten werden
durchgaengig entfernt (Endpoint, Migration, UI, Startseiten-Sektion), da
nur Bewerbungen gemerkt werden sollen.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 14:58:18 +02:00
thomasandClaude 3a108d3fec Jobangebote: Favoriten (Stern) und Startseiten-Sektion ganz oben
Stellen lassen sich per Stern merken — unabhängig vom Status (offen /
uebernommen / abgelehnt). Neue Spalte jobangebote.favorit (PRAGMA-Migration),
JSON-Toggle-Endpunkt /jobangebote/:id/favorit, Stern-Button auf den Karten
sowie eine "Gemerkte Stellen"-Sektion ganz oben auf der Startseite. Toggle
laeuft per fetch mit optimistischem UI; beim Entfernen auf der Startseite
verschwindet die Zeile sofort.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 14:49:47 +02:00
thomasandClaude 2566dcc98d Einrichtung: Wizard um E-Mail, Kalender und REST-API erweitert
Der Assistent deckt nun alle Bereiche aus /einstellungen ab. Neue Schritte
fuer E-Mail (SMTP/IMAP) & CalDAV sowie REST-API; Indikator auf 7 Schritte.
Secrets (Passwort, Token) werden nie vorausgefuellt und beim Speeren leerer
Felder nicht ueberschrieben. Neuer Endpunkt /api/einrichtung/config mit
SSRF-Guard (Mail-Host, CalDAV-URL, Ollama-Host) und API-Token-Kollisionspruefung.
Abschluss-Checkliste trennt Pflicht- von optionalen Verbindungen.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 12:55:20 +02:00
thomasandClaude 1bc0188fcc Einrichtung: Setup-Assistent fuer neue Nutzer auf der Startseite
Nutzer ohne Bewerbungen sehen einen mehrstufigen Wizard, der Persoenliche
Angaben, Basis-Unterlagen und Ollama-KI per fetch speichert. Dashboard bleibt
im DOM (hidden), damit main.js und "Bewerbung manuell anlegen" funktionieren.
Neue Endpunkte /api/einrichtung/{persoenlich,basis,ollama,verstecken} mit
SSRF-Guard; Ollama gezielt via setForUser, ohne Mail/Kalender/Token zu leeren.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 12:48:26 +02:00
thomasandClaude Opus 4.8 92ade10a37 Statische Dateien: JS/CSS revalidieren statt blind cachen
express.static setzte kein Cache-Control, also legte der Browser die
Lebensdauer selbst fest (heuristisch, aus dem Alter der Datei). Folge: nach
einem Deploy lief die Oberflaeche im offenen Tab weiter mit dem alten
chat.js - die frisch ausgelieferten Tabellen wurden dort weiter als
Pipe-Wueste gerendert, obwohl der Server die neue Datei schon hatte.

no-cache heisst nicht "nicht cachen": der Browser behaelt die Datei, fragt
aber per ETag nach, ob sie noch gilt (304, kein erneuter Download).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:33:07 +02:00
thomasandClaude Opus 4.8 090228479a Jobsuche: Berufsbezeichnungen steuern die Suche, nicht mehr Faehigkeiten
Der Feinschliff schlug per KI Technologien vor ("Proxmox", "Rechenzentrum")
und verbot Rollenbezeichnungen ausdruecklich - die sollte die Suche selbst
aus dem Lebenslauf ableiten. Das war schief: Welche Anzeigen ueberhaupt
gefunden werden, entscheidet die Suchanfrage, also der Stellentitel.
Technologien gewichten nur, was schon da ist. Damit schlug die KI genau das
vor, was der Such-Agent ohnehin selbst ableitet, waehrend die eine Sache,
die der Nutzer weder sieht noch steuern kann - unter welchen Titeln gesucht
wird -, bei jedem Lauf neu improvisiert wurde. Faellt "Fachinformatiker
Systemintegration" weg, merkt das niemand; es kommt nur wenig zurueck.

- Neues Profilfeld berufsbezeichnungen: die KI schlaegt es vor, der Nutzer
  editiert es, der Such-Agent fragt jeden Titel einzeln ab.
- zusatz_begriffe bleibt, aber als Kontext (Branche, Umfeld, Arbeitszeit),
  nicht als Stellentitel; ausschluesse unveraendert.
- Leeres Feld = altes Verhalten (Titel aus dem Lebenslauf), niemandes
  Profil aendert sich durch das Update.
- Spalte wird fuer bestehende Installationen nachgezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:27:06 +02:00
thomasandClaude Opus 4.8 67b8e5735f KI-Chat: Thread-Titel vom Modell formulieren lassen
Der Titel in der Seitenleiste waren bisher die ersten 60 Zeichen der
ersten Nachricht - abgeschnitten mitten im Wort ("Ich habe mich bei der
Hetzner Online GmbH bewor..."). Jetzt formuliert das Modell daraus einen
kurzen Titel, wie man es aus ChatGPT kennt.

- Der Aufruf laeuft NACH dem done-Event: die Antwort wartet nicht auf die
  Titelgenerierung (1-3 s), der Titel kommt als eigenes Event nach und die
  Seitenleiste zieht live nach.
- Der abgeschnittene Titel bleibt als Platzhalter, solange die Antwort
  streamt - die Seitenleiste ist also nie leer, und faellt die
  Titelgenerierung aus, bleibt es schlicht dabei.
- Nur beim ersten Schlagabtausch und nur, wenn der Thread noch keinen
  Titel hatte: ein selbst vergebener Name wird nicht ueberschrieben.
- Eigener Mini-Systemprompt statt des Chat-Prompts, der will beraten,
  nicht betiteln. Anfuehrungszeichen/"Titel:"-Praefixe werden abgeraeumt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:19:27 +02:00
thomasandClaude Opus 4.8 4b3e66b4bd KI-Chat: Markdown-Tabellen und Trennlinien werden gerendert
Der Mini-Renderer kannte weder Tabellen noch ---, also landeten beide als
roher Text in einem Absatz: aus einer Anforderung/Erfahrung-Tabelle wurde
eine einzeilige Pipe-Wueste, aus --- blieb ---. Das Modell nutzt aber
gerade fuer Passungs-Vergleiche gern Tabellen.

- GFM-Tabellen inkl. Ausrichtung (:--:), auch ohne Rand-Pipes.
- Krumme Zeilen werden auf die Kopfbreite gezogen (fehlende Zellen leer,
  ueberzaehlige raus), sonst zerreisst eine Zeile das Raster.
- Wrapper scrollt horizontal, damit eine breite Tabelle die Chat-Blase
  auf dem Handy nicht auseinanderzieht.
- Zellinhalte laufen durch dieselbe Escape-/inlineFmt-Kette wie der Rest,
  HTML aus dem Modell kann also weiterhin nichts injizieren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:15:25 +02:00
thomasandClaude Opus 4.8 4a036fe215 KI-Chat: Web-Recherche ueber Ollama Web Search
Der Assistent kannte bisher nur die Datenbank. Fragen wie "Was muss ich
ueber die Firma wissen?" konnte er damit nicht beantworten - Groesse,
Produkte, News, Kultur stehen nirgends in der Bewerbung.

Zwei neue Werkzeuge (web_suche, web_seite_lesen) ueber die Web-Search-API
von ollama.com. Sie authentifizieren sich mit demselben OLLAMA_API_KEY,
den der Benutzer fuer Chat und Dokumente ohnehin hinterlegt hat: keine
zusaetzliche Konfiguration. Anders als /api/chat haengt die Websuche
nicht an OLLAMA_HOST - wer rein lokal chattet, hat schlicht keine
Websuche, der Rest laeuft unveraendert weiter.

- Tool-Runden 4 -> 8: Bewerbung holen, suchen, Seiten lesen, antworten
  lief vorher ins Limit, statt zu antworten.
- Antworten der Websuche hart gedeckelt, sie wandern sonst in jeder
  weiteren Runde erneut ins Kontextfenster.
- Status-Label zeigt, wonach gesucht wird - sonst sieht der Nutzer nicht,
  ob die richtige Firma erwischt wurde.
- Nackte URLs im Chat sind klickbar: Belege nennt das Modell meist als
  reine URL, nicht als Markdown-Link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:02:29 +02:00
thomasandClaude Opus 4.8 9fc4225204 Aktivitaeten: eigene Seite mit Pagination, kaputte Eintragslinks repariert
Der Link unter "Letzte Aktivitaeten" heisst jetzt "Alle" und fuehrt auf
/aktivitaeten - den vollstaendigen Verlauf ueber alle Bewerbungen, 25 Eintraege
je Seite. Die Blaetterleiste sind echte Links (kein JS), damit Zurueck-Taste,
Lesezeichen und Neuer-Tab funktionieren; eine Seitenzahl ausserhalb des Bereichs
wird auf den gueltigen Rand geklemmt statt eine leere Seite zu zeigen.

Dabei ein Fehler von mir aus dem letzten Commit behoben: Ich hatte den Tag-Namen
samt href per <%= %> zusammengesetzt, was die Anfuehrungszeichen escaped -
heraus kam href="&quot;/bewerbung/121&quot;" und damit die URL
/%22/bewerbung/121%22. Jetzt zwei ausgeschriebene Zweige (Link bzw. Block).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:42:02 +02:00
thomasandClaude Opus 4.8 7b60fe734a Startseite: Letzte Aktivitaeten und Gesamtzahl der Bewerbungen
Unter der Liste stehen jetzt die acht juengsten Statusaenderungen quer ueber
alle Bewerbungen - mit Firma, Stelle, Status, Datum und Kommentar, verlinkt auf
die Bewerbung. Sortiert nach Erfassungszeit statt nach fachlichem Datum, damit
ein nachgetragener alter Eintrag nicht oben steht.

Der Kopf der Listen-Karte nennt die Gesamtzahl. Er sitzt bewusst ausserhalb des
md:min-w-Rahmens, damit er auf dem Handy nicht horizontal mitscrollt.

Beides verschwindet waehrend einer Suche, wie Liste und Termine auch. Die
Status-Farbkarte ist eine Ebene hoeher gewandert, weil Liste und Aktivitaeten
sie jetzt beide brauchen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:33:39 +02:00
thomasandClaude Opus 4.8 ab62399f6f Mobile: Glocken-Panel und Bewerbungsliste liefen aus dem Bild
Benachrichtigungen: Das Panel haengt per right:0 am Wrapper der Glocke, rechts
von ihr stehen aber noch Konto und Burger - die 20rem Breite wuchsen dadurch
nach links aus dem Viewport (gemessen -51px bei 375px Breite). Unter 768px
spannt es sich jetzt ueber den Header: `position: static` auf dem Anker macht
den Header zum Bezugsrahmen, was auch die vertikale Lage inklusive
Impersonation-Banner richtig haelt.

Bewerbungsliste: Das gestapelte Karten-Layout griff erst unter 640px, die
minmax()-Spalten des Rasters brauchen aber ~764px und die Karte bekommt ihre
min-w-[760px] erst ab md (768px). Im Band dazwischen - Handy quer, grosse
Phones - galt also das Desktop-Raster, und Stelle, Art und Status lagen
ausserhalb (132px Ueberhang bei 667px). Grenze auf 767px gezogen, damit sie an
md anschliesst.

Nachgemessen im Headless-Browser bei 375/390/412/667/740/820px: nichts ragt
mehr heraus, die Liste scrollt nicht mehr horizontal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:27:22 +02:00
thomasandClaude Opus 4.8 e462fa18ad Beispieldaten entpersonalisiert: generische Staedte statt Gladbeck & Co.
Platzhalter und Doku-Beispiele nannten durchgehend den Wohnort des
Entwicklers (Gladbeck, Bottrop, Gelsenkirchen, Essen), dessen Vorwahl
(02043), PLZ und Strasse. Ersetzt durch Berlin/Hamburg/Muenchen sowie eine
zum Beispielort passende Vorwahl (030). Betrifft die Platzhalter fuer
Telefon/Ort (Vorlagen), die Staedte-Eingabe (Jobsuche), das Adressbeispiel
in den LLM-Notizen, die OpenAPI-Beispiele und zwei Code-Kommentare.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:14:06 +02:00
thomasandClaude Opus 4.8 5ce835ff3a Bewerbungsstatus "Vertragsverhandlung" ergaenzt
Neue Station zwischen Vorstellungsgespraech und Einstellung - in der
Statusliste, der Gruppierung auf der Startseite, den Farbskalen (Lime,
zwischen dem Gelb des Gespraechs und dem Gruen der Einstellung), im
PDF-Export, in der Statistik und in der REST-API/OpenAPI.

Der Statistik-Funnel zaehlte bisher ever('Vorstellungsgespraech') +
ever('Einstellung') und damit eine Bewerbung, die beide Stationen
durchlaufen hat, doppelt. Mit einer weiteren Stufe waere das schlimmer
geworden, deshalb zaehlt der Funnel jetzt pro Bewerbung statt pro
Statuszeile: jede Stufe zaehlt eine Bewerbung genau einmal, ein spaeterer
Status impliziert die frueheren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:10:02 +02:00
thomasandClaude Opus 4.8 2b90e4bbad Login: "Angemeldet bleiben" waehlbar, Multi-User-Hinweis entfernt
Bisher bekam jeder Login pauschal ein 30-Tage-Cookie. Die Checkbox ist per
Default gesetzt (unveraendertes Verhalten); wird sie abgewaehlt, gilt ein
Session-Cookie ohne Ablaufdatum (weg beim Schliessen des Browsers) und
serverseitig eine Frist von 12 Stunden Inaktivitaet - die serverseitige Frist
ist die verbindliche, ein Client kann sein Cookie manipulieren.

Bestehende Sessions behalten per Spalten-Default die 30 Tage, niemand wird
durch das Update ausgeloggt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:03:33 +02:00
thomasandClaude Opus 4.8 d86e40a6ff KI-Feinschliff, Volltextsuche über E-Mails, Prompt für die Antwort-Mail
Drei Erweiterungen, die sich server.js und lib/documents.js teilen und
deshalb zusammen liegen:

Jobsuche: Der Feinschliff (Zusatzbegriffe/Ausschluesse) laesst sich per KI
aus den eigenen Basis-Unterlagen vorschlagen. Der Rollen-Prompt ist wie die
uebrigen unter Vorlagen editierbar; gespeichert wird erst auf Klick.

Suche: Startseite und KI-Chat durchsuchen jetzt per SQLite-FTS5 auch die
E-Mail-Korrespondenz (Absender, Betreff, Text), Notizen und
Stellenbeschreibung - ein Name aus einer Mail findet die Bewerbung. Ranking
per bm25, Fundstellen mit Snippet, Feldfilter (firma:, von:), Phrasen,
Ausschluesse, Umlaut-Varianten (mueller/muller/mueller). Der Index wird von
SQL-Triggern gepflegt, weil Bewerbungen und Mails aus UI, REST-API, IMAP und
KI-Import geschrieben werden.

E-Mail: Vor dem Generieren laesst sich vorgeben, was in der Antwort stehen
soll; die Vorgabe geht als verbindliche Anweisung in den Prompt. Steht schon
ein Entwurf im Feld, ueberarbeitet die KI ihn, statt neu anzufangen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:56:41 +02:00
thomasandClaude 3c7f5d7c74 Mobile-Support: Smartphones für die ganze Seite
- Dashboard-Bewerbungsliste auf Handy als gestapelte Karten statt horizontal gescrolltem 760px-Raster
- Modals auf Handy scrollbar (Inhalt wurde bisher unten abgeschnitten)
- Chat: Thread-Aktionen auf Touch sichtbar, Sidebar/Chat-Höhen für mobil
- Bewerbungs-Antwort-Toolbar & Admin-Stat-Boxen umbruchfähig

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 10:24:49 +02:00
thomasandClaude 295c447d58 PDF-Export: /api/export monats/jahresbezogen gab 500 (ungueltiges SQL)
Die gefilterten Export-Zweige bauten `SELECT * FROM (<subquery>) AND
strftime(...)` — ohne WHERE nach der abgeleiteten Tabelle. SQLite
quittierte das mit "near \"AND\": syntax error" => HTTP 500, der Client
erhielt {error:'Serverfehler'} statt des Bewerbungs-Arrays und
generatePdfDocument crashte mit "applications.map is not a function".

Fix: `AND` => `WHERE` in den drei gefilterten Zweigen (month+year,
month, year). Die Per-User-Isolation bleibt unberuehrt (user_id-Filter
steht weiterhin in der Subquery). Zusaetzlich prueft generatePDF jetzt
Array.isArray(applications) und zeigt eine klare Fehlermeldung statt
des .map-Crashs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 02:38:47 +02:00
thomasandClaude 69e6a29d87 PDF-Export: Autotable-Plugin zuverlaessig laden vor der Generierung
loadPdfLibraries() hat im "bereits ladend"-Zweig aufgeloest, sobald jsPDF
vorhanden war — das Autotable-Plugin (zweites Script) war dann aber noch
nicht angehaengt, sodass doc.autoTable undefined war und die Generierung
mit "doc.autoTable is not a function" fehlschlug. Die Polling-Bedingung
prueft jetzt auch jsPDF.API.autoTable, und generatePDF wartet zusaetzlich
auf loadPdfLibraries(), bevor das Dokument gebaut wird.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 02:35:37 +02:00
thomasandClaude 167960b91a Benutzerverwaltung: übersichtliches Layout mit klaren Aktionen
- Header mit Icon + Statistikleiste (Benutzer / Admins / Bewerbungen)
- Karten-Layout (rund, Shadow), Sektionen mit Icons
- Aktionen als einheitliche Icon+Label Buttons (Jobsuche, Anmelden als,
  Passwort, Löschen) statt gequetschter Textlinks, je eigene Farbe
- Avatar-Initial + Admin-Badge mit Icon in der Tabelle
- Passwort-Reset als eingeblendete Karte mit Abbrechen + Hinweis
- Relative Zeitangaben (Erstellt, Audit) mit Tooltips
- Audit-Sektion als eigene Karte

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 02:21:32 +02:00
thomasandClaude e0733666ae Admin kann Nutzer-Konto übernehmen (Impersonation) mit Rückwechsel
- sessions.impersonator_id: Admin-Session behält Token, schaltet user_id
  aufs Ziel, Admin bleibt in impersonator_id gespeichert (Stack, keine
  Verschachtelung). uid()/Config/Dateien laufen als Ziel-Nutzer.
- Admin sieht in /admin pro Nutzer "Anmelden als"; Bestätigungsdialog.
- Dauerhaftes amber Banner im Header mit "Zurück zum Admin" (POST, kein JS
  nötig) erscheint auf jeder Seite während Impersonation.
- requireAdmin verweigert während Impersonation -> keine Admin-Aktionen als
  fremder Nutzer; Stop-Route prüft impersonator_id (kein Escalation-Pfad für
  Normalnutzer). Selbst-Imitation blockiert.
- audit_log-Tabelle protokolliert Start/Stop persistent; Admin-Seite zeigt
  Audit-Liste (/admin/audit/impersonations).
- Migration: idempotentes ALTER ADD COLUMN impersonator_id fuer Bestand.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 02:17:50 +02:00
thomasandClaude fd1db3970f Sicherheitscheck: Schwachstellen behoben
- Pfad-Traversal: safeFilename/containedPath-Helper, storeAnhang sanitizes
  filename, alle Download/Delete/Mail-Send-Routen pruefen Containment
- Stored XSS: serveInline entscheidet Viewable-Typ nur nach Extension,
  nicht nach client/seitigem MIME; nicht viewbare Typen werden als Download
  erzwungen. Upload fileFilter (Basis/Interne) + Extension-Validierung
  (Signatur/Foto leiten Ext aus MIME, blockieren .html)
- URL-Scheme-Allowlist (safeUrl) fuer quelle_url-hrefs gegen javascript:-XSS
- E-Mail-Iframe: Sandbox auf allow-same-only (kein allow-popups-to-escape)
- Sicherheits-Header: CSP, X-Content-Type-Options, X-Frame-Options,
  Referrer-Policy, COOP; x-powered-by aus; jsPDF self-hosted unter /vendor
- Session: Secure-Flag bei TLS, serverseitige absoluteexpiry, Scrypt async
  + Dummy-Verify gegen Timing/Enumerate + Login-Rate-Limit
- Open Redirect: /email/fetch nur same-origin Redirects
- SSRF: Validierung von OLLAMA_HOST/CALDAV_URL/MAIL_HOST gegen
  Metadata/Link-Local-BLock (localhost/LAN bleibt erlaubt)
- Globaler Error-Handler ohne Interna-Leak, env=production

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 02:09:17 +02:00
thomasandClaude da0b497972 Wortmarke in der Top-Navi einfarbig
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 01:43:49 +02:00
thomasandClaude bf789af62a Rebranding zu NextJobs (nextjobs.cc)
- Markenname 'Bewerbungs-Tracker' -> 'NextJobs' im gesamten UI,
  Footer (incl. nextjobs.cc Copyright), Login, Wortmarke in der
  Top-Navi (Next + Jobs-Gradient), Favicon, Swagger/OpenAPI-Titel,
  CalDAV-PRODID, Browser-Erweiterung (manifest, content, background),
  Runner-Log und Doku.
- Funktionale Begriffe (Bewerbungsunterlagen, -datum etc.) bleiben.
- Docker-Registry (git.hackner.dev) als Infrastruktur unberuehrt.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 01:40:26 +02:00
thomasandClaude cc2df90653 Favicon und moderner Schriftzug fuer die Top-Navi
- Passendes SVG-Favicon (Gradient-Mark wie in der Top-Navi) +
  theme-color, registriert im <head>
- 'Bewerbungs-Tracker' als zweifarbiger Schriftzug: Bewerbungs in
  Slate, Tracker im Sky->Blue-Gradient

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 01:33:26 +02:00
thomasandClaude fc28bee55f Footer dezenter, an Top-Navi angelehnt
Keine eigene Oberflaeche mehr (nur Seitenhintergrund + Haarlinie),
kleinere und stummere Schrift — gleiche ruhige Sprache wie die
Top-Navigation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 01:27:46 +02:00
thomasandClaude 17d438d102 Jobsuche-Runner: nvm laden, damit Cron node findet
Node laeuft unter nvm, dessen bin-Verzeichnis nicht im Cron-PATH
lag. Jeder Cron-Tick schlug mit `node: command not found` fehl,
weshalb manuell angeforderte Suchlaeufe ewig im Status 'angefordert'
(wartet) haengen blieben. nvm.sh wird jetzt vor dem Aufruf gesourced.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 01:04:59 +02:00
thomasandClaude 9f000e2719 Filterbereich entfernt, Suche + Aktionen als Toolbar zusammengefasst
- Monat/Jahr-Filter geloescht (Server-Filter bleibt via URL nutzbar)
- Suchfeld und die Buttons 'PDF Export' und 'Bewerbung hinzufuegen'
  in einer gemeinsamen Toolbar zusammengefasst, als Pill-Buttons
- Tote filterForm-CSS und -JS entfernt

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 00:56:24 +02:00
thomasandClaude 98a644524b Statistik auf eigene Seite ausgelagert, umfangreich und modern
- Eigene Route /statistik mit umfassender Auswertung pro Benutzer
- KPI-Karten, Monats-Trend (SVG), Verteilung nach Status/Art/Ort,
  Bewerbungs-Funnel und letzte Aktivitaet als Timeline
- Statistik-Eintrag in der Top-Navigation (Desktop + Mobil)
- Kurzsstatistik vom Dashboard entfernt

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 00:48:08 +02:00
thomasandClaude 4fd843318f Header/Navigation-Styling ueberarbeitet
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 00:34:49 +02:00
thomasandClaude Opus 4.8 1aa902bdce Top-Navigation: neu strukturiert, mit Aktiv-Zustand und Dropdowns
Die Leiste war eine flache Reihe aus sieben gleichrangigen Pillen. Zwei
Probleme: sie zeigte nie an, wo man gerade ist (es gab schlicht keinen
Aktiv-Zustand), und auf dem Handy fielen nur die Beschriftungen weg - uebrig
blieben sieben unbeschriftete Icons.

Struktur folgt jetzt dem tatsaechlichen Ablauf (finden -> bewerben ->
antworten) statt der Reihenfolge, in der die Seiten entstanden sind:

  Bewerbungen | Jobangebote | Stellensuche v | Postfach | Vorlagen

"Stellensuche" buendelt als Dropdown, was Angebote erzeugt und filtert:
Suchprofil & Laeufe, Uebernommene Angebote, Blacklist - jeweils mit einer Zeile,
die erklaert, wozu der Punkt gut ist. Einstellungen, Benutzerverwaltung und
Abmelden wandern ins Konto-Menue rechts, wo Systemkram hingehoert. Die
taeglichen Wege (Bewerbungen, Jobangebote, Postfach) bleiben ein Klick weit weg.

Gestaltung: Die Leiste behaelt in beiden Themes ihr dunkles Navy - sie ist der
Rahmen des Werkzeugs und die einzige Flaeche, die sich beim Navigieren nie
aendert. Alles darin ist bewusst still (Slate auf Navy); der einzige Akzent ist
der aktive Punkt, markiert durch eine schmale leuchtende Schiene an der
Unterkante. Beim Hover deutet sich dieselbe Schiene gedaempft an.

Zaehler (ungelesene Post, offene Angebote) haengen jetzt an Klassen statt IDs,
weil sie in Leiste UND Mobilmenue erscheinen. Ein Controller bedient alle
Dropdowns: Klick oeffnet, andere schliessen, Escape und Klick daneben schliessen,
aria-expanded bleibt ehrlich. Fokus ist sichtbar, prefers-reduced-motion wird
respektiert, das Mobilmenue zeigt alle Punkte ausgeschrieben und gruppiert.

Der aktive Pfad kommt aus res.locals.pfad (Auth-Middleware). Die Navigation liegt
weiterhin in genau einem Partial - sie ist damit auf allen Seiten identisch;
nur die Login-Seite hat bewusst keine.

Geprueft mit echtem Browser (Screenshots): hell/dunkel, Dropdowns, Mobilmenue,
und der Aktiv-Zustand je Seite - Unterseiten wie /blacklist oder /jobsuche heben
korrekt "Stellensuche" hervor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:55:58 +02:00
thomasandClaude Opus 4.8 9418750061 Jobsuche als Feature der App: Suchprofil pro Benutzer statt Cron-Prompt
Die Jobsuche lag in zwei Host-Skripten (jobsuche-cron.sh, jobsuche-remote-cron.sh)
aus der Ein-Benutzer-Zeit: Die Suchkriterien (sieben Staedte, Rollen, Buzzwords)
standen fest im Prompt, und importiert wurde mit EINEM globalen API-Token aus der
.env - der zufaellig dem Admin gehoerte. Auf der Multi-User-Plattform ist beides
hinfaellig.

Jetzt legt jeder Benutzer sein Suchprofil selbst fest (/jobsuche):
- Modus: regional / 100 % Remote / beides
- Staedte (die erste gilt als Wohnort und wird hoechstpriorisiert, max. 12)
- Feinschliff: zusaetzliche Begriffe, Ausschluesse
- Zeitplan: Wochentage + Uhrzeit, plus Button "Jetzt suchen"
Rollen und Technologien bleiben abgeleitet - aus dem Lebenslauf des Benutzers
(basis_dokumente), nicht aus einer gepflegten Liste. Admins koennen Profil und
Zeitplan eines Benutzers ueber /jobsuche?user=<id> mitpflegen (Link im Admin-Panel).

Aufteilung App/Host: Der Container hat weder claude noch ollama. Die App reiht
Laeufe daher nur in die Warteschlange ein (Tabelle suchlaeufe); der neue Runner
auf dem Host (scripts/jobsuche-runner.js, Cron alle 5 Min) arbeitet sie ab, baut
den Prompt je Benutzer aus dessen Profil + Lebenslauf und laeuft mit DESSEN
Zugangsdaten:
- BEWERBUNG_API_KEY = eigener API-Token des Benutzers (wird beim ersten Lauf
  automatisch erzeugt), damit Treffer im richtigen Konto landen und durch dieselbe
  Dedup-/Blacklist-Logik gehen,
- ANTHROPIC_BASE_URL/AUTH_TOKEN = eigener Ollama-Key des Benutzers ueber die
  Anthropic-kompatible Schnittstelle von Ollama Cloud (https://ollama.com/v1/messages,
  verifiziert: gueltiger Key -> 200, ungueltiger -> 401). Damit zahlt jeder seine
  eigene Suche, statt alles ueber die Host-Subscription zu buchen
  (JOBSUCHE_KI_AUTH=host stellt das alte Verhalten wieder her).

Ohne eigenen Ollama-Key oder ohne Lebenslauf bricht der Lauf mit klarer Meldung ab
statt still nichts zu tun; die Oberflaeche warnt vorab. Kein Stapeln: solange ein
Lauf offen ist, erzeugt ein weiterer Klick keinen zweiten. Verwaiste Laeufe
(Prozess weg) werden nach Zeitlimit als Fehler freigegeben.

Verifiziert mit zwei Benutzern: Zugriffsschutz (fremdes Profil -> 403), Speichern,
Warteschlange, automatische Key-Erzeugung, Prompt-Aufbau aus Profil + CV,
Ergebnis-Ruecklauf in die Oberflaeche, Fehlerpfade und die Faelligkeitslogik des
Zeitplans (Tag/Uhrzeit/bereits gelaufen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:28:56 +02:00
thomasandClaude Opus 4.8 1e14738258 API: Zugriff strikt auf den Besitzer des API-Keys, Key generieren/anzeigen
Der X-API-Key identifiziert den Benutzer; alle Endpunkte waren bereits auf
dessen user_id gescoped (27 Operationen geprueft). Zwei Luecken blieben:

- Die Aufloesung Token -> Benutzer nahm bei mehrdeutigem Token per LIMIT 1
  einfach den ersten Treffer. Haetten zwei Benutzer denselben Token, saehe
  der eine die Daten des anderen. Jetzt: fail closed (401 + Logeintrag),
  und /einstellungen weist einen bereits vergebenen Token mit 409 ab.
- Ein leerer Token galt als Wert: wer seine Einstellungen einmal gespeichert
  hatte, besass eine API_TOKEN-Zeile mit ''. Leere Werte matchen jetzt nie.

Neu in den Einstellungen (Abschnitt REST-API):
- "Neu generieren" erzeugt einen zufaelligen Token (32 Byte, crypto.get-
  RandomValues); er wird nur ins Feld gefuellt und erst beim Speichern
  aktiv, ein Fehlklick laesst sich also verwerfen.
- "Kopieren" legt den Token in die Zwischenablage; das Auge blendet ihn ein
  (bestand bereits fuer Secret-Felder).

API aktualisiert:
- PUT /settings kannte nur name/adresse/kundennummer, GET lieferte aber alle
  acht Felder. Jetzt schreibt PUT alle (email, telefon, ort, webseite,
  geburtsdatum) und aendert nur die im Body uebergebenen Felder; die Antwort
  enthaelt den neuen Stand.

Swagger:
- Beschreibung sagte "konfiguriert via Umgebungsvariable API_TOKEN" - das
  gilt seit der Multi-User-Umstellung nicht mehr. Jetzt dokumentiert:
  Token pro Benutzer aus den Einstellungen, Zugriff nur auf eigene Daten,
  fremde id -> 404, unbekannter/leerer/mehrdeutiger Token -> 401.
- Settings-Schema um die fehlenden fuenf Felder ergaenzt.

Verifiziert mit zwei Benutzern und je eigenem Key: Lesen, Aendern, Loeschen,
Timeline, Anhaenge, E-Mails, Generierung, Jobangebote und Blacklist des
jeweils anderen liefern durchgaengig 404; Listen, Export und Statistik
zeigen nur eigene Daten; kollidierender Token -> 409; mehrdeutiger Token in
der DB -> 401 fuer beide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:56:21 +02:00
thomasandClaude Opus 4.8 b6cb94fdb5 Einstellungen strikt pro Benutzer: kein process.env-Fallback mehr
config.get() ist bei einem nicht gesetzten Schluessel auf process.env
zurueckgefallen. Da die Env-Variablen die Konfiguration des Admins
enthalten (Docker-Env: MAIL_*, CALDAV_URL, OLLAMA_API_KEY, API_TOKEN),
hat damit JEDER neu angelegte Benutzer ohne eigene Einstellungen
stillschweigend die Zugangsdaten des Admins geerbt:

- /einstellungen zeigte ihm die Zugangsdaten des Admins an.
- mailer/caldav isConfigured() war true -> der IMAP-Poller hat fuer den
  neuen Benutzer das Postfach des Admins abgerufen und dessen E-Mails in
  sein Konto einsortiert; CalDAV synchronisierte den Kalender des Admins.
- Der bezahlte Ollama-Key des Admins wurde mitbenutzt.

Jetzt:
- config.get() loest ausschliesslich die Zeilen des aktuellen Benutzers auf,
  sonst den eingebauten Standard (nicht-geheime Werte wie Modell, Host,
  Ports, Intervalle). Alle Credentials sind bei neuen Benutzern leer,
  d. h. Ollama/E-Mail/CalDAV/API sind fuer sie aus, bis sie sich selbst
  etwas eintragen.
- Noch per Env gesetzte Konfiguration wird einmalig in die Zeilen des
  ADMIN uebernommen (importEnvIntoAdmin, Aufruf beim Boot nachdem
  app_state existiert - in runMigration war das bei Neuinstallationen ein
  No-op, weil die Tabelle dort noch nicht angelegt ist).
- config.ensureLoaded(user.id) beim Aufloesen der Session bzw. des
  X-API-Key. config.get() ist synchron und liest den Per-User-Cache; ohne
  Warmladen las ein Web-Request die Werte als "nicht konfiguriert". Das
  hat bisher der env-Fallback verdeckt (er hielt zufaellig die Werte des
  Admins) - ohne ihn muss die Config pro Request wirklich geladen werden.

Verifiziert gegen eine Kopie der Produktions-DB mit Sentinel-Env-Werten:
Admin behaelt seine kompletten Einstellungen, der zweite Benutzer sieht
ueberall leere Credentials, Mail/CalDAV sind fuer ihn inaktiv, und der
Env-API-Token wird nicht mehr als gueltiger X-API-Key akzeptiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:31:08 +02:00
thomasandClaude Opus 4.8 b2e884d9b3 Persoenliche Angaben: fehlende settings-Zeile ist ein gueltiger Zustand
Neu angelegte Benutzer hatten keine settings-Zeile - die legte bisher nur
die Migration von Hand fuer den Admin an, POST /admin/users dagegen nicht.
Der Code ging aber davon aus, dass die Zeile immer existiert:

- /vorlagen lieferte fuer neue Benutzer 500: die View greift auf
  settings.name zu, bekam aber undefined.
- PUT /api/v1/settings verwarf Schreibzugriffe stillschweigend: das blanke
  UPDATE traf null Zeilen und meldete trotzdem success.

Statt Platzhalter-Zeilen zu provisionieren ist "keine Zeile" jetzt ueberall
ein gueltiger Zustand - genau wie bei prompts, design und app_state:

- loadSettings() (neben loadPrompts()/loadDesign()) liefert {} statt
  undefined; alle sechs Lesestellen gehen darueber.
- PUT /api/v1/settings ist ein Upsert, GET liefert {} statt leerem Body.
- Chat-Tool bewerbung_detail: JOIN auf jobangebote zusaetzlich ueber
  j.user_id = b.user_id, wie derselbe JOIN an anderer Stelle.

Repariert auch bereits angelegte Benutzer ohne Backfill-Migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:16:45 +02:00
thomasandClaude 0371aa85a5 Multi-User-Plattform: jeder Benutzer hat eigene, isolierte Daten
- Auth via Session-Cookie + Login-Seite (scrypt, lib/password.js, sessions-Tabelle)
- AsyncLocalStorage (lib/context.js) propagiert aktuellen Benutzer durch alle Libs
- user_id auf allen Datentabellen (FK->users ON DELETE CASCADE), per-user PK/UNIQUE
  (app_state, settings, prompts, design, jobangebote) und per-user Dateispeicher
  (data/<dir>/<userId>/)
- Alle Queries in server.js + lib/api.js nach user_id scope-iert
- Pro-Benutzer-Konfiguration (Ollama/Mail/CalDAV/API-Token) in app_state,
  Live gelesen via config.get(); Hintergrund-Loops (IMAP/CalDAV) iterieren alle Benutzer
- REST-API /api/v1: X-API-Key loest den Token zu einem Benutzer auf, Anfragen
  operieren nur auf dessen Daten
- Admin-Panel /admin: Benutzer anlegen, Passwort zuruecksetzen, loeschen (mit Daten)
- Idempotente Migration (lib/migrate-multiuser.js + scripts/migrate-to-multiuser.js):
  bestehende Daten werden dem Benutzer admin:admin zugeordnet

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 21:57:50 +02:00
thomasandClaude 97b48f9841 Einstellungen-Seite: .env-Werte in DB (app_state), keine .env mehr
Neue /einstellungen-Seite (Zahnrad im Header) mit allen bisherigen .env-Werten
(Ollama, E-Mail, CalDAV, REST-API), gespeichert in SQLite (app_state, cfg:-Prefix).
Libs lesen per lib/config.js zur Laufzeit statt beim Start -> Aenderungen
wirken sofort, kein Neustart. Bestehende .env wird beim ersten Start einmalig
migriert.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 20:57:31 +02:00
thomasandClaude 41887dd56c Status "Absage von meiner Seite" ergaenzt
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 12:51:37 +02:00
thomasandClaude 0c866805d3 Dark Mode folgt Browser-Einstellung (prefers-color-scheme), Toggle entfernt
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 12:34:56 +02:00
thomasandClaude e97d1b3687 Persoenliche Angaben unter Vorlagen statt hardcoded/Settings-Modal
- settings-Tabelle um email/telefon/ort/webseite/geburtsdatum erweitert
  (CREATE + ALTER-Migration + Default-INSERT)
- Vorlagen-Seite: neuer Bereich "Persoenliche Angaben" (8 Felder),
  POST /vorlagen/persoenlich; Settings-Modal + Zahnrad-Button entfernt
- Generierung: Kontakt kommt verbindlich aus den Settings (buildHeader
  laesst gespeicherte Werte ueber die KI-Extraktion gewinnen), bewerber-
  Fakten + Prompt angepasst; Design-Vorschau zeigt echte Kontaktdaten
- main.js: Settings-Modal-Handling entfernt; /api/settings bleibt bestehen

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 11:41:32 +02:00
thomasandClaude Opus 4.8 d1116d522d Vor dem Generieren waehlen: Anschreiben, Lebenslauf oder beides
Auf der Bewerbungsseite laesst sich jetzt auswaehlen, welche Unterlagen die KI
erzeugt. Standard bleibt beides; die letzte Auswahl wird gemerkt und ist beim
naechsten Generieren vorbelegt. Ohne Haken bricht das Formular ab statt still
beides zu erzeugen.

Nicht gewaehlte Dokumente werden gar nicht erst angefragt: JSON-Schema und
Skelett im Prompt werden auf die gewuenschten Teile reduziert, das spart einen
Gutteil der Generierungszeit. Gerendert wird ebenfalls nur das Gewaehlte - auch
wenn das Modell sich nicht an das Schema haelt und trotzdem alles liefert.

Fuer den E-Mail-Versand mitgedacht:
- Wird kein Lebenslauf erzeugt, fuehrt ihn das Anschreiben nicht mehr unter
  "Anlagen" auf. Sonst kuendigt der Brief eine Anlage an, die nie mitgeht.
- Der Prompt bekommt zusaetzlich die Liste der Dateien, die der Bewerbungsmail
  tatsaechlich anhaengen (inkl. Anschreiben selbst), damit der Begleittext keine
  Unterlagen nennt, die nicht dabei sind.
- Die Anhang-Checkboxen im Mailformular ziehen sich aus den erzeugten Anhaengen,
  greifen also automatisch.

Die REST-API kennt das Feld ebenfalls (POST .../generate, "dokumente"), inkl.
OpenAPI-Doku.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:18:02 +02:00
thomasandClaude Opus 4.8 66366659d2 Social-Design: Herz-Abstaende korrigiert + Icons fuer die Kontaktdaten
Die Abschnittsueberschriften wurden mit align:'center' zentriert. jsPDF misst
dafuer mit getTextWidth(), das den charSpace ignoriert, rendert den Text aber
gesperrt - er lief nach rechts, waehrend die Herzen symmetrisch sassen. Ergebnis:
links sichtbar mehr Luft als rechts. Die Ueberschrift wird jetzt selbst zentriert,
gemessen an der tatsaechlichen Tinte (Sperrung zaehlt nur zwischen den Glyphen,
nicht nach dem letzten), und die Herzen haengen an den echten Textkanten.

Kontaktdaten bekommen passende Icons: Umschlag, Telefon, Map-Pin, Globus,
Kalender, Ausweis. Als Vektoren gezeichnet, weil kein Icon-Glyph in der Schrift
vorausgesetzt werden kann; sie skalieren mit der Seitenskalierung mit.
buildContactLines liefert dafuer jetzt {typ, text} - die klassische Sidebar nutzt
weiterhin nur den Text und bleibt unveraendert schlicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 01:30:56 +02:00
thomasandClaude Opus 4.8 0e1d9b3322 Zweites Lebenslauf-Design "Social Media" + Anschreiben folgt dem Layout
Der Lebenslauf kann jetzt zwischen zwei Layouts waehlen, und das Anschreiben
uebernimmt immer das Layout des Lebenslaufs - beide Dokumente kommen als ein Set
beim Unternehmen an.

"Social Media" ist die verspielte Variante nach der Media-Kit-Vorlage: getoente
Seite, rundes Portrait mit Blob, Schreibschrift-Zeile ueber einem grossen Namen,
Herzchen als Abschnittsmarker und Bullets, abgerundete Karten je Station,
Pill-Chips fuer Kompetenzen und Sprachen, Glitzer-Sterne als Deko. Bewusst laut -
gedacht fuer kreative und Social-Media-Stellen.

Die gesamte Farbwelt (Seite, Karten, Pills, Deko) wird aus der Akzentfarbe
abgeleitet, das Layout funktioniert also auch in Violett oder Tiefblau. Neue
Akzente Pink und Violett; das Layout bringt eigene Defaults mit (pink, rundes
Foto), sodass ein Wechsel sofort stimmig aussieht.

Neue Schriften (SIL OFL): Poppins fuer die geometrische Sans, Pacifico fuer die
Schreibschrift. Es werden nur die Fonts eingebettet, die das gewaehlte Layout
nutzt - ein klassischer Lebenslauf traegt Poppins/Pacifico nicht mit.

Herz und Sparkle sind als Bezier-Pfade gezeichnet, weil sich auf ein Herz-Glyph
in der Schrift nicht verlassen werden kann. Das neue Layout nutzt dieselbe
Mess- und Skalier-Mechanik wie das klassische, erbt also die Ein-Seiten-Garantie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 01:15:02 +02:00
thomasandClaude Opus 4.8 9f7349af4e Design der Unterlagen unter Vorlagen einstellbar
Akzentfarbe, Sidebar-Hintergrund, Foto (an/aus, eckig/rund) und Schriftgroesse
lagen als Konstanten im Renderer. Sie kommen jetzt aus lib/design.js und sind
unter Vorlagen waehlbar, inklusive Vorschau-PDF mit Musterinhalten - die zeigt
das Ergebnis, ohne dass die KI laufen muss.

Bewusst geschlossen gehalten: die Palette ist eine kurze Liste gedeckter Toene
(kein freier Color-Picker), Grauwerte und Grundlayout bleiben fest. Die
Schriftgroesse ist nur der Startwert der Seitenskalierung - der Lebenslauf passt
weiterhin garantiert auf eine Seite.

Das Theme wird durch die Render-Funktionen gereicht statt als Modulzustand
gesetzt, damit spaeter mehrere Bewerber parallel generieren koennen, ohne sich
gegenseitig das Design umzustellen.

Rundes Foto: das Bild fuellt den Kreis formatfuellend und zentriert (Clip statt
Skalieren), sonst wuerde ein Hochformat-Foto im Quadrat gestaucht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:51:36 +02:00
thomasandClaude Opus 4.8 65a5943993 KI-Prompts unter Vorlagen editierbar statt hardcoded
Rolle, Tonfall und Regeln der KI (Unterlagen, Chat, E-Mail-Antwort,
E-Mail-Absage, gemeinsame Stilregeln) lagen fest im Code. Sie liegen jetzt
als Defaults in lib/prompts.js und lassen sich auf der Vorlagen-Seite je
Prompt anpassen und wieder zuruecksetzen.

Nur die System-Prompts sind editierbar. Die User-Prompts tragen das
JSON-Skeleton, gegen das die Antwort geparst wird - ein Tippfehler dort
wuerde die Generierung lahmlegen, also bleiben sie im Code.

Gespeichert wird nur, was abweicht: ein Override ist eine Zeile in der neuen
Tabelle `prompts`, "Zuruecksetzen" loescht sie. Damit bleiben die Defaults im
Code die Wahrheit und wandern bei Updates automatisch mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:09:03 +02:00
thomasandClaude 0a0b0b4724 E-Mail: KI-Absage generieren (andere Stelle angenommen)
Im Antwort-Formular gibt es neben dem KI-Button jetzt ein Dropdown Antwort/Absage.
Für Absage formuliert die KI eine kurze, höfliche E-Mail: Bewerber hat eine andere
Stelle angenommen, bedankt sich, zieht sich aus dem Prozess zurück.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 20:51:37 +02:00
thomasandClaude 0c7502cfd7 KI-Chat: Bewerbungsdaten per Tool-Calling nachschlagen statt in Prompt stopfen
Der Systemprompt enthielt früher nur die 12 jüngsten Bewerbungen (LIMIT 12),
daher fand die KI ältere Firmen wie QBS KLIMTAX nicht. Alle Bewerbungen in den
Prompt zu laden machte den Chat sehr langsam, da der Prompt bei jedem Turn
voll neu verarbeitet wird.

Jetzt nutzt der Assistent Ollama-Tool-Calling, um Datenbankdaten on demand
nachzuschlagen. Der Systemprompt bleibt klein und konstant (nur Profil +
Datum), unabhängig von der Anzahl der Bewerbungen.

- lib/chat.js: streamChat mit tools + runChat-Tool-Schleife (max 4 Runden)
- server.js: Tools suche_bewerbungen/list_bewerbungen/bewerbung_detail/
  kommende_termine mit SQL-Queries; gatherChatContext auf Kern reduziert
- public/js/chat.js: Tool-Indikator ("durchsucht Bewerbungen…") im UI

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 05:41:14 +02:00
thomasandClaude 306026b0a1 Interne Notizen: Anhang-Upload repariert + schöneres Design
Der Upload- und Lösch-Formular war versehentlich inside des
Bewerbungsdaten-Formulars verschachtelt – HTML lässt verschachtelte
Formulare nicht zu, weshalb der Browser das multipart-Formular
ignorierte und die Datei nie gesendet wurde. Formulare jetzt outside
des Speichern-Formulars. Dazu ansprechendere Karte-Optik mit
Öffnen-/Download-/Löschen-Aktionen.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 04:46:33 +02:00
thomasandClaude 0b44f04456 Interne Notizen: eigene Anhänge (PDFs/Dateien) nur für dich
Unter den internen Notizen lassen sich nun private Anhänge hochladen.
PDFs öffnen per Klick direkt im Browser; Anhänge werden weder in den
PDF-Export noch in den Bewerbungsversand einbezogen.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-09 04:37:52 +02:00
thomasandClaude 40b4de12e1 KI-Chat: Verlaufs-Titel live aktualisiert + sauberes Floating-Icon
- Der automatisch generierte Verlaufs-Titel (aus der ersten Nachricht) erscheint
  jetzt sofort in der Sidebar, nicht erst nach Reload. Der titel wird im
  done-Event mitgeschickt und clientseitig gesetzt.
- Floating-Button-Icon durch eine saubere Chat-Bubble ersetzt.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 14:57:35 +00:00
thomasandClaude 26a61aa302 KI-Chat: Verlaufs-Titel inline umbenennbar
Pro Verlauf ein Stift-Button (neben dem Löschen-Button). Klick macht den
Titel inline editierbar; Enter/Blur speichert via PATCH /chat/api/threads/:id,
Escape bricht ab. Auch für client-seitig neu angelegte Threads.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 14:55:06 +00:00
thomasandClaude d2545c4faf KI-Chat: Warte-Indikator als gestaffelte Bounce-Animation
Drei Punkte mit gestaffeltem Animation-Delay (klassische Tipp-Animation),
inkl. prefers-reduced-motion-Fallback (statisch).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 14:48:04 +00:00
thomasandClaude 61b67e0843 KI-Chat: Verlauf wird beim Laden angezeigt + einzelne Chats löschbar
- initialMessages-JSON wurde mit <%= %> HTML-escaped, wodurch JSON.parse
  fehlschlug und die Nachrichtenfläche beim Öffnen eines Verlaufs leer blieb.
  Jetzt <%- %> mit < -> < escapet, JSON bleibt parsebar.
- Pro Verlauf ein Trash-Button in der Sidebar (DELETE /chat/api/threads/:id).
  Löscht den Verlauf, entfernt die Zeile, setzt bei aktivem Verlauf auf
  neuen Chat zurück. Auch für client-seitig neu angelegte Threads.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 14:42:29 +00:00
thomasandClaude cf6cf9ee33 KI-Chat: reichhaltigerer Kontext (Stellenbeschreibung, Korrespondenz, Profil)
Der Assistent kannte nur Firma/Stelle/Status/Notizen und konnte daher keine
firmenspezifischen Fragen beantworten. gatherChatContext lädt jetzt pro
Bewerbung: Ort, interne Notizen, Stellenbeschreibung (bzw. verknüpftes
Jobangebot), Kontakt/Ansprechpartner und die E-Mail-Korrespondenz (Betreffe).
Zusätzlich wird der Lebenslauf des Bewerbers injiziert, damit Antworten auf
"was für mich wichtig" zugeschnitten werden können.

Relevante Bewerbungen = Termin-Bewerbungen + 12 jüngste (gebunden im Token-Budget).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 14:36:45 +00:00
thomasandClaude cc397e8f71 KI-Chat: Markdown-Rendering, Left-Alignment, kompaktere UI, "Neuer Chat" fix
- Markdown-Renderer (Headings/Listen/**bold*/*italic*/`code`/Links) für
  Assistenten-Antworten; HTML wird vorher escaped (keine Injektion).
- Antworten explizit linksbündig, Inhalt getrimmt (keine Leerzeilen oben/unten).
- Initiale Nachrichten als JSON-Blob, einheitlich via chat.js gerendert.
- Chat-Container kompakter (Höhe/Spacing reduziert).
- "Neuer Chat" setzt clientseitig auf leeren Zustand zurück (vorher lud der
  Button nur den letzten Thread neu).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 14:28:54 +00:00
thomasandClaude c389d3d5ec KI-Chat: Kontext-Injection repariert (SQL from-Alias) + Datum im Prompt
gatherChatContext() brach mit SQLITE_ERROR ab, weil "e.from_addr AS from"
das SQL-Schlüsselwort from als Alias nutzte. Der Fehler wurde im Route-Handler
geschluckt (context={}), sodass der Assistent keinen Zugriff auf Bewerbungen/
Termine/E-Mails hatte. Alias entfernt.

Zusätzlich erhält der System-Prompt jetzt das heutige Datum, damit der
Assistent "morgen"/"heute" aus den UTC-Termin-Zeitstempeln ableiten kann.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 14:21:13 +00:00
thomasandClaude 8656e92f3c KI-Chat: Eingabefeld bei neuem/leerem Chat nicht mehr disabled
Das Textfeld war ohne vorhandenen Thread deaktiviert, sodass die erste
Frage (die den Thread erst anlegt) nicht eintippbar war. disabled entfernt;
chat.js legt den Thread weiterhin beim ersten Absenden an.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 14:14:00 +00:00
thomasandClaude ead70efa49 KI-Chat: interaktiver Bewerbungs-Assistent (Ollama-Streaming)
Eigenes Chat-Interface mit SSE-Streaming gegen das hinterlegte Ollama-Modell,
gegroundet in den Bewerbungs-/E-Mail-/Termindaten des Nutzers.

- lib/chat.js: streamChat (Ollama stream:true, NDJSON-Token) + buildContextPrompt
- chat_threads/chat_messages Tabellen (CASCADE, Index)
- Routen: GET /chat, Thread-CRUD, POST /messages (SSE, AbortController)
- views/chat.ejs + public/js/chat.js + Floating-Button im Footer
- hasApiKey-Gating (503 ohne OLLAMA_API_KEY)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-07 14:07:38 +00:00
thomasandClaude Opus 4.8 17df46cb6c Reply-Mail: Zeilenumbrüche als <br> statt white-space:pre-wrap
Ausgehende Antwort-Mails (multipart/alternative) rendern den HTML-Teil
bisher über white-space:pre-wrap auf rohen Zeilenumbrüchen ohne <br>.
Beim ersten Empfang korrekt, aber sobald der Empfänger-Client (Outlook)
die Mail zitiert, wird der pre-wrap-Stil verworfen und die nackten
Newlines zu Leerzeichen kollabiert -> unser formatiertes Anschreiben wird
im Zitat zur zusammenhanglosen Textwand. Explizite <br> überleben diesen
Round-Trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 09:18:44 +00:00
thomasandClaude Opus 4.8 4e1043897d Blacklist: Firmen schreibweisen-robust matchen (eine Firma nur einmal)
sameCompany() vergleicht Firmen-Slugs jetzt per Gleichheit ODER führendem
Bindestrich-Präfix (>=2 Tokens), sodass Kurzname und voller Firmenname
derselben Firma als gleich gelten (z. B. "it-problemloeser" =
"it-problemloeser-verwaltungs-und-handels", "stadtwerke-essen" =
"stadtwerke-essen-netz"). firmaMatch() nutzt das für die Blacklist-Typen
firma/firma_stelle/auto. Distinkte Firmen mit nur gleichem ersten Wort
("meyer-it" vs "meyer-logistik") bleiben getrennt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 07:25:04 +00:00
thomasandClaude Opus 4.8 7e0c27341a CalDAV: HTML-Entities in Terminfeldern dekodieren
Manche Kalender speichern SUMMARY/LOCATION/DESCRIPTION mit wörtlichen
HTML-Entities (z. B. "Vorstellungsgespr&#228;ch &#8211; dot.haus"). Diese
wurden roh angezeigt. Beim Parsen werden sie jetzt in echte Zeichen
dekodiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:58:07 +02:00
thomasandClaude Opus 4.8 22f967c753 Carry firma_slug on job offers, supplied by the ingesting client
The blacklist company slug now lives on the offers themselves. POST /joboffers
accepts firma_slug and stores it as supplied (deriving it from firma only when
omitted); it is persisted on jobangebote (schema + ALTER/backfill + index) and
returned in the Joboffer response. Blacklist matching prefers the client-supplied
slug over one derived from firma, so the indexing client controls the identity
used to block an offer. OpenAPI request/response schemas updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:42:41 +02:00
thomasandClaude Opus 4.8 f935e00b53 Add robust company slug for job-offer blacklist matching
Company matching relied on normText(), which kept legal-form suffixes,
spacing and umlaut spelling — so "Bosch GmbH", "Bosch AG" and "bosch gmbh"
did not match the same entry, making the blacklist unreliable.

Introduce firmaSlug(): transliterate German umlauts, strip diacritics and
trailing legal-form tokens (GmbH/AG/SE/KG/…), then kebab-join. Add a
firma_slug field to jobangebote_blacklist (schema + ALTER/backfill migration)
and match on it for typ firma/firma_stelle/auto, falling back to firma_norm
for legacy rows. POST /joboffers rejects blacklisted offers via matchBlacklist
automatically since offerSignature now carries the slug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:25:59 +02:00
thomasandClaude Opus 4.8 4e5933890f Footer: Copyright hackner.dev ergänzen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:50:04 +02:00
thomasandClaude Opus 4.8 3b694edac6 Dark-Mode vor erstem Paint setzen (kein Light-Mode-Flash mehr)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:46:49 +02:00
thomasandClaude Opus 4.8 30f83d433a Gesendete Anhänge in Korrespondenz anzeigen (email_anhaenge verknüpfen)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:47:07 +02:00
thomasandClaude Opus 4.8 4003279453 Anhänge (Unterlagen + Mailanhänge): PDF im neuen Tab öffnen statt Download
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:42:12 +02:00
thomasandClaude Opus 4.8 d99c8885fc Antwort-Betreff: kein doppeltes Re:/AW: voranstellen
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:55:43 +02:00
thomasandClaude Opus 4.8 7dddbe18b1 Antwort-Mails: hinterlegte Anlagen mitschicken (Standard: keine)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:48:39 +02:00
thomasandClaude Opus 4.8 c2c51f9fca Neue Bewerbungs-Status: In Bearbeitung, Interessiert, Warten auf Rückmeldung/meine Antwort
Vier mittlere Pipeline-Status ergänzt (nach Eingangsbestätigung, vor
Vorstellungsgespräch), inkl. Farben/Reihenfolge in Übersicht, Detailseite
und PDF-Export sowie REST-API-Validierung und Swagger-Enum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 10:06:56 +02:00
thomasandClaude Opus 4.8 03760d293f Labels für Stellen (Bewerbungen + Jobangebote), inkl. API & Swagger
Mehrere Labels pro Stelle (Regional, Remote-Deutschlandweit,
Homeoffice-Deutschlandweit), gespeichert als JSON-Array in einer neuen
labels-Spalte beider Tabellen (Migration). Geteiltes lib/labels.js mit
parse/serialize; wiederverwendbare Partials fuer Chips + Mehrfachauswahl.

- Web: setzen im Hinzufuegen-Modal, auf der Bearbeiten-Seite und im
  Jobangebot-Bearbeiten-Formular; Anzeige als Chips in den Listen.
- Uebernahme eines Angebots traegt dessen Labels in die neue Bewerbung.
- REST-API: labels[] in /applications und /joboffers (GET/POST/PUT),
  Filter ?label=…; OpenAPI/Swagger-Schemas + Enums erweitert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:23:44 +02:00
thomasandClaude Opus 4.8 d1c6743d2f PDF-Export: keine zerrissenen Zeilen mehr + HTML-Entities dekodieren
rowPageBreak 'avoid' verhindert das Splitten einer Zeile am Seitenumbruch,
das bisher Datum/Art/Status auf der Folgeseite leer liess. Firma/Stelle/Art
werden HTML-dekodiert (z.B. "&amp;" -> "&"), leere Werte zeigen "—".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:20:27 +02:00
thomasandClaude Opus 4.8 78b1ea78d0 PDF-Export: kompakte Tabelle, Filter nach letztem Status-Datum
Verbose Blöcke (Notizen + kompletter Verlauf) durch eine autoTable ersetzt:
eine Zeile pro Bewerbung mit Datum, Firma, Stelle, Art und letztem Status
(farbiges Badge). Export filtert/datiert nun nach dem effektiven Datum, also
der letzten Statusaenderung – eine im Juni gesendete, im Juli zum Gespraech
gewordene Bewerbung erscheint dadurch im Juli-Export. Modal-Auswahl nutzt
dieselben effektiven Monate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:12:24 +02:00
thomasandClaude Opus 4.8 b269dcaecc Footer immer am Seitenende (Sticky) und Text auf "Bewerbungssystem"
Body als Flex-Column, main mit flex-1 drueckt den Footer nach unten.
Footer-Jahr serverseitig gerendert (immer aktuell).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:40:55 +02:00
thomasandClaude Opus 4.8 82431490a9 Startseite: Bewerbungsliste als klappbare Sektionen (eine Zeile pro Bewerbung)
Tabelle auf Grid-Layout umgebaut. Status-Gruppen und der Detailbereich
(Verlauf/Notizen) je Bewerbung sind animiert zuklappbar, Default zugeklappt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:36:29 +02:00
thomasandClaude Opus 4.8 7cc527b013 Glocke im Header ganz nach rechts verschieben
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:48:36 +02:00
thomasandClaude Opus 4.8 a44b319e7e Benachrichtigungsglocke fuer ungelesene E-Mails
Neue Antworten werden meist automatisch einer Bewerbung zugeordnet und
tauchten daher nie im Postfach auf - man bemerkte sie nicht. Eine Glocke
im Header zeigt jetzt auf jeder Seite die Zahl ungelesener empfangener
E-Mails (inkl. zugeordneter Antworten) und listet sie in einem Dropdown
mit Absender, Betreff, Auszug und Link zur Bewerbung bzw. zum Postfach.

- GET /api/notifications: Anzahl + neueste ungelesene Nachrichten.
- POST /api/emails/mark-all-read: alle als gelesen markieren.
- Postfach-Ansicht markiert unverknuepfte Mails als gelesen (leert die
  Glocke), zugeordnete Antworten werden beim Oeffnen der Bewerbung gelesen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:39:05 +02:00
thomasandClaude Opus 4.8 b1531663bb E-Mails als HTML anzeigen; Antworten zitieren Vornachricht (auch KI)
- HTML-Mails werden in einem sandboxed iframe (ohne allow-scripts)
  gerendert statt als Quelltext angezeigt; Höhe an Inhalt angepasst,
  Skripte/Tracking laufen nicht, Layout bleibt isoliert. Reine
  Text-Mails weiterhin als pre-wrap. Gilt für Postfach und Bewerbung.
- Antwortformular ist wie im Mailclient mit der zitierten Vornachricht
  vorbelegt (Attribution + "> "-Zeilen), Cursor darüber.
- KI-Antwort hängt das Zitat unter den generierten Text und erhält den
  bereinigten Klartext (statt HTML) als Eingabe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:06:59 +02:00
82 changed files with 17676 additions and 2352 deletions
-1
View File
@@ -1,6 +1,5 @@
node_modules
data
extension
indeed_example.txt
.env
.env.*
+7 -4
View File
@@ -1,11 +1,14 @@
# Kopiere diese Datei nach .env und trage deinen Schlüssel ein.
# .env wird NICHT eingecheckt (siehe .gitignore).
# HINWEIS: Diese Werte werden seit dem Umzug auf die Datenbank-basierte
# Konfiguration primär in der Weboberfläche unter „Einstellungen“ (/einstellungen)
# gepflegt. Eine bestehende .env-Datei wird beim ersten Start einmalig in die
# Datenbank migriert; danach ist die Datenbank die einzige Quelle und .env wird
# nicht mehr benötigt. Diese Datei dient nur noch als Vorlage/Referenz.
# Ollama Cloud API-Schlüssel https://ollama.com/settings/keys
OLLAMA_API_KEY=
# Optional Cloud-Modell (Standard: gpt-oss:120b)
# OLLAMA_MODEL=gpt-oss:120b
# Optional Cloud-Modell (Standard: glm-5.2:cloud)
# OLLAMA_MODEL=glm-5.2:cloud
# Optional anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434)
# OLLAMA_HOST=https://ollama.com
+1
View File
@@ -31,6 +31,7 @@ COPY server.js ./
COPY lib ./lib
COPY views ./views
COPY public ./public
COPY extension ./extension
# Persisted SQLite data lives here; make it writable for the non-root user
RUN mkdir -p /app/data && chown -R node:node /app
+23 -15
View File
@@ -1,4 +1,4 @@
# Bewerbungs-Tracker
# NextJobs
Ein professioneller Job Application Tracker für Jobcenter Grundsicherung Monatsberichte.
@@ -23,27 +23,28 @@ Ablauf:
Standard-Anschreiben und den Lebenslauf als Text einfügen. Diese Basis-Unterlagen
sind die Faktengrundlage; die KI erfindet nichts hinzu.
2. **Stelle importieren** Mit der Browser-Erweiterung (`extension/`) auf Indeed den
Button **„An Bewerbungs-Tracker senden"** klicken.
Button **„An NextJobs senden"** klicken.
3. **Automatische Verarbeitung** Der Tracker legt die Bewerbung mit Status
**Entwurf** an und generiert im Hintergrund über die **Ollama Cloud API**
zugeschnittene Bewerbungsunterlagen, die als PDF-Anhänge an der Bewerbung hängen.
### Konfiguration
Alle Einstellungen (Ollama, E-Mail, Kalender, REST-API) werden in der Weboberfläche
unter **„Einstellungen“** (`/einstellungen`, Zahnrad-Symbol im Header) gepflegt und
in der Datenbank gespeichert. Änderungen wirken sofort, ein Neustart ist nicht nötig.
Für die KI-Generierung wird ein Ollama-Cloud-API-Schlüssel benötigt
([ollama.com/settings/keys](https://ollama.com/settings/keys)). Am einfachsten über
eine (nicht eingecheckte) `.env`-Datei kopiere `.env.example` nach `.env`:
([ollama.com/settings/keys](https://ollama.com/settings/keys)) trage ihn nach dem
ersten Start unter **Einstellungen → Ollama Cloud** ein.
```bash
cp .env.example .env
# in .env eintragen:
# OLLAMA_API_KEY=...
# OLLAMA_MODEL=gpt-oss:120b # optional, Standard
# OLLAMA_HOST=https://ollama.com # optional; z. B. http://localhost:11434 für lokale Ollama
npm start
# dann im Browser http://localhost:3000/einstellungen öffnen
```
Alternativ als Umgebungsvariable: `export OLLAMA_API_KEY=...`.
Bestehende Installationen, die bisher eine `.env`-Datei nutzen, werden beim ersten
Start einmalig in die Datenbank migriert; danach wird `.env` nicht mehr benötigt.
Ohne Schlüssel wird die Bewerbung trotzdem als Entwurf angelegt; die Generierung
schlägt dann kontrolliert mit einem Hinweis fehl und kann später per
@@ -161,10 +162,17 @@ bewerbungs-tracker/
- Sonstiges
### Status
- Entwurf
- Gesendet
- Eingangsbestätigung
- In Bearbeitung
- Interessiert
- Warten auf Rückmeldung
- Warten auf meine Antwort
- Vorstellungsgespräch
- Vertragsverhandlung
- Absage
- Absage von meiner Seite
- Einstellung
- Keine Rückmeldung
@@ -217,10 +225,10 @@ das Rohdokument unter **`/swagger.json`**.
### Authentifizierung
Jeder Endpunkt (außer `GET /api/v1/health`) erfordert einen API-Key im Header
`X-API-Key`. Der Schlüssel wird über die Umgebungsvariable `API_TOKEN` konfiguriert
(z. B. in `.env`, siehe `.env.example`). Ist `API_TOKEN` nicht gesetzt, antwortet
die API mit `503` sie gibt nie ungeschützt Daten heraus. Swagger/UI sind
auch ohne Token erreichbar (die Dokumentation enthält keine sensiblen Daten).
`X-API-Key`. Der Schlüssel wird unter **Einstellungen → REST-API** (`API_TOKEN`)
konfiguriert. Ist `API_TOKEN` nicht gesetzt, antwortet die API mit `503` sie gibt
nie ungeschützt Daten heraus. Swagger/UI sind auch ohne Token erreichbar (die
Dokumentation enthält keine sensiblen Daten).
```bash
curl -H "X-API-Key: $API_TOKEN" http://localhost:3000/api/v1/applications
@@ -288,7 +296,7 @@ MIT
## Autor
Bewerbungs-Tracker für Jobcenter Grundsicherung
NextJobs für Jobcenter Grundsicherung
---
+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 @@
# Bewerbungs-Tracker Indeed Import (Browser-Erweiterung)
# NextJobs Stellen-Import (Browser-Erweiterung)
Chromium-Erweiterung (Manifest V3), die auf Indeed einen Button **direkt neben der
Stellenbeschreibung** einfügt. Ein Klick sendet alle Stelleninformationen an den
Bewerbungs-Tracker. Dort wird die Stelle als **Entwurf** angelegt und die KI
erstellt automatisch zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten
Vorlagen).
Chromium-Erweiterung (Manifest V3), die **auf jeder Webseite** Stellen
ausliest und an NextJobs (`https://nextjobs.cc`) sendet. Auf Indeed wird
zusätzlich ein Button *direkt neben der Stellenbeschreibung* eingeblendet.
Die Stelle wird im Tracker als **Entwurf** angelegt; die KI erstellt daraus
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)
1. Bewerbungs-Tracker starten (Standard: `http://localhost:3000`).
2. In Chrome/Edge/Brave `chrome://extensions` öffnen.
3. **Entwicklermodus** oben rechts aktivieren.
4. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner auswählen.
## 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.
1. In Chrome/Edge/Brave `chrome://extensions` öffnen.
2. **Entwicklermodus** oben rechts aktivieren.
3. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner
auswählen.
4. API-Key eintragen (siehe oben).
## Verwendung
1. Auf `indeed.com` eine Stelle öffnen.
2. Neben der Überschrift **„Vollständige Stellenbeschreibung"** erscheint der grüne
Button **„An Bewerbungs-Tracker senden"**.
3. Klicken → die Stelle wird als Entwurf angelegt, die Unterlagen werden im
Hintergrund generiert. Über den Link „Entwurf öffnen" gelangst du direkt zur
Bewerbung im Tracker.
1. Auf einer Stellenanzeige das Erweiterungs-Symbol klicken. Die Felder werden
automatisch ausgelesen (Schema.org/JSON-LD → OpenGraph → Auswahl/Text).
Text auf der Seite markieren → wird als Beschreibung übernommen.
2. Felder prüfen, ggf. korrigieren und **An Tracker senden** klicken.
Auf Indeed alternativ den grünen Button **„An NextJobs senden"** neben
der Stellenbeschreibung.
3. Über den Link **Entwurf öffnen** gelangst du direkt zur Bewerbung im Tracker.
## Voraussetzungen im Tracker
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben, Lebenslauf)
hinterlegen — diese dienen der KI als Faktengrundlage.
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt z. B. in der `.env`-Datei des Trackers ,
damit die KI-Generierung läuft.
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben,
Lebenslauf) hinterlegen — diese dienen der KI als Faktengrundlage.
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt z. B. in den Einstellungen des
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) |
|------|---------------------|
| Stelle | `[data-testid="jobsearch-JobInfoHeader-title"]` |
| Firma | `[data-testid="inlineHeader-companyName"]` (im `jobsearch-CompanyInfoContainer`) |
| Ort | `[data-testid="jobsearch-JobInfoHeader-companyLocation"]` |
| Gehalt/Art | `#salaryInfoAndJobType` |
| Stellenbeschreibung | `#jobDescriptionText` |
| Quelle-URL | aus `vjk`/`jk`-Parameter bzw. aktueller URL |
| Berechtigung | Zweck |
|---|---|
| `storage` | API-Key speichern (`chrome.storage.sync`) |
| `scripting` + `activeTab` | Stelle auf der aktiven Seite auslesen (nur auf Klick) |
| `https://nextjobs.cc/*` | Import an den Tracker senden |
| `*://*.indeed.com/*` | Inline-Button auf Indeed einblenden |
Indeed ist eine Single-Page-App; die Erweiterung erkennt Wechsel der geöffneten
Stelle per `MutationObserver` und hält den Button aktuell.
Die Erweiterung liest nur die aktive Seite aus, wenn du sie aufrufst, und
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 Bewerbungs-Tracker.
// Routing the request through the background worker (instead of the content
// script) keeps it independent of the Indeed page's Content-Security-Policy.
// Service worker: performs the cross-origin POST to NextJobs.
// Routing the request through the background worker (instead of a content
// 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) => {
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
let url = (items.trackerUrl || DEFAULT_TRACKER_URL).trim();
url = url.replace(/\/+$/, ''); // strip trailing slashes
resolve(url);
chrome.storage.sync.get({ apiKey: '' }, (items) => {
resolve((items.apiKey || '').trim());
});
});
}
@@ -18,10 +21,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message && message.type === 'IMPORT_JOB') {
(async () => {
try {
const base = await getTrackerUrl();
const res = await fetch(base + '/api/indeed-import', {
const apiKey = await getApiKey();
if (!apiKey) {
sendResponse({ ok: false, needKey: true, error: 'Kein API-Key hinterlegt.' });
return;
}
const res = await fetch(TRACKER_URL + '/api/indeed-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
body: JSON.stringify(message.payload),
});
@@ -39,6 +46,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
});
return;
}
if (res.status === 401) {
sendResponse({ ok: false, needKey: true, error: 'API-Key abgelehnt (401). Bitte in den Einstellungen prüfen.' });
return;
}
sendResponse({
ok: false,
error: (data && data.error) || `Server antwortete mit ${res.status}`,
@@ -50,14 +61,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
ok: true,
id: data.id,
message: data.message,
openUrl: data.url ? base + data.url : null,
openUrl: data.url ? TRACKER_URL + data.url : null,
});
} catch (err) {
sendResponse({
ok: false,
error:
'Konnte den Bewerbungs-Tracker nicht erreichen. Läuft er, und ist die ' +
'Adresse in den Erweiterungs-Einstellungen korrekt? (' + String(err.message) + ')',
'Konnte NextJobs nicht erreichen (' + String(err.message) + '). ' +
'Prüfe deine Internetverbindung und ob der API-Key in den Einstellungen korrekt ist.',
});
}
})();
+3 -3
View File
@@ -1,4 +1,4 @@
// Content script: injects an "An Bewerbungs-Tracker senden" button next to the
// Content script: injects an "An NextJobs senden" button next to the
// Indeed job description, scrapes the job details and forwards them to the
// tracker's import API (via the background service worker).
@@ -103,7 +103,7 @@
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bt-import-btn';
btn.innerHTML = ICON_SEND + '<span>An Bewerbungs-Tracker senden</span>';
btn.innerHTML = ICON_SEND + '<span>An NextJobs senden</span>';
const status = document.createElement('div');
status.className = 'bt-import-status';
@@ -147,7 +147,7 @@
setStatus(status, '', '');
chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: payload }, (resp) => {
btn.innerHTML = ICON_SEND + '<span>' + (originalLabel || 'An Bewerbungs-Tracker senden') + '</span>';
btn.innerHTML = ICON_SEND + '<span>' + (originalLabel || 'An NextJobs senden') + '</span>';
if (chrome.runtime.lastError) {
btn.disabled = false;
+6 -9
View File
@@ -1,15 +1,12 @@
{
"manifest_version": 3,
"name": "Bewerbungs-Tracker Stellen-Import",
"version": "1.1.0",
"description": "Erfasst Stellen von jeder Webseite (nicht nur Indeed) über das Erweiterungs-Popup und sendet sie an den Bewerbungs-Tracker, der automatisch zugeschnittene Bewerbungsunterlagen erstellt.",
"name": "NextJobs Stellen-Import",
"version": "2.0.0",
"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"],
"host_permissions": [
"*://*.indeed.com/*",
"http://localhost/*",
"http://127.0.0.1/*",
"http://*/*",
"https://*/*"
"https://nextjobs.cc/*",
"*://*.indeed.com/*"
],
"content_scripts": [
{
@@ -24,6 +21,6 @@
},
"action": {
"default_popup": "popup.html",
"default_title": "Bewerbungs-Tracker"
"default_title": "NextJobs"
}
}
+4 -3
View File
@@ -94,10 +94,11 @@
<div id="status"></div>
<details id="settings">
<details id="settings" open>
<summary>Einstellungen</summary>
<label for="trackerUrl">Tracker-Adresse</label>
<input type="url" id="trackerUrl" placeholder="http://localhost:3000" />
<label for="apiKey">REST-API-Key</label>
<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">
<button class="primary" id="saveBtn">Speichern</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 statusEl = $('status');
function normalize(url) {
return (url || '').trim().replace(/\/+$/, '');
}
function setStatus(html, cls) {
statusEl.innerHTML = html;
statusEl.className = cls || '';
}
// ---- Settings (tracker URL) ------------------------------------------------
// ---- Settings (API-Key) ----------------------------------------------------
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
$('trackerUrl').value = items.trackerUrl || DEFAULT_TRACKER_URL;
chrome.storage.sync.get({ apiKey: '' }, (items) => {
$('apiKey').value = items.apiKey || '';
});
$('saveBtn').addEventListener('click', () => {
const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL;
chrome.storage.sync.set({ trackerUrl: url }, () => {
$('trackerUrl').value = url;
setStatus('Einstellungen gespeichert.', 'ok');
const key = $('apiKey').value.trim();
chrome.storage.sync.set({ apiKey: key }, () => {
setStatus(key ? 'API-Key gespeichert.' : 'API-Key entfernt.', key ? 'ok' : '');
});
});
$('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 …', '');
try {
const res = await fetch(url + '/api/settings', { method: 'GET' });
setStatus(res.ok ? 'Verbindung erfolgreich ✓' : 'Erreichbar, aber unerwartete Antwort (' + res.status + ').', res.ok ? 'ok' : 'err');
const res = await fetch(TRACKER_URL + '/api/v1/health', {
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) {
setStatus('Nicht erreichbar: ' + err.message, 'err');
}
@@ -158,6 +162,13 @@ function sendJob(payload) {
setStatus('Fehler: ' + chrome.runtime.lastError.message, 'err');
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) {
$('sendBtn').disabled = false;
if (confirm(duplicateWarning(resp.matches))) {
+230 -125
View File
@@ -1,18 +1,34 @@
// Third-party REST API (v1) for the Bewerbungs-Tracker.
// Third-party REST API (v1) for the NextJobs.
//
// Mounted under /api/v1 in server.js. All endpoints except /health require an
// API key (env API_TOKEN) sent in the X-API-Key header. Reuses the server's
// existing DB helpers, sanitizer, duplicate guard, generation runner and
// attachment directories so behaviour stays consistent with the web UI.
// API key sent in the X-API-Key header. The key is per-user: it is the
// API_TOKEN the user saved on /einstellungen. The auth step resolves the key to
// the owning user, then runs every request inside that user's context
// (lib/context.js) so all queries + file paths are scoped to that user — the
// third-party software only ever sees and creates the user's own data.
const express = require('express');
const path = require('path');
const fs = require('fs');
const blacklist = require('./blacklist');
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
const { normalizeDokumente, standardDokumente } = require('./documents');
const { userContext, currentUserId } = require('./context');
const config = require('./config');
const CONFIG_PREFIX = 'cfg:';
// Replace the stored labels JSON string with a real array on outgoing rows.
function withLabels(row) {
if (row) row.labels = parseLabels(row.labels);
return row;
}
const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Absage', 'Einstellung', 'Keine Rückmeldung',
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Vertragsverhandlung',
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
];
function createExternalApi(deps) {
@@ -27,29 +43,65 @@ function createExternalApi(deps) {
runGeneration,
anhaengeDir,
emailAnhaengeDir,
apiToken,
userStorageDir,
} = deps;
const router = express.Router();
// The current user's id, resolved from the per-request context set below.
const uid = () => currentUserId();
// --- API key auth --------------------------------------------------
// /health is public so monitoring tools can probe availability; everything
// else returns 401 when the header is missing/wrong or the token isn't set.
router.use((req, res, next) => {
// /health is public so monitoring tools can probe availability. Every other
// endpoint resolves the X-API-Key to the user who owns that token (stored in
// app_state as cfg:API_TOKEN), then wraps the rest of the request in that
// user's context so all queries/files are scoped to them. A missing or
// unknown key yields 401.
router.use(async (req, res, next) => {
if (req.path === '/health') return next();
if (!apiToken) {
return res.status(503).json({ error: 'API-Token nicht konfiguriert (API_TOKEN-Umgebungsvariable fehlt).' });
}
const provided = req.get('X-API-Key');
if (!provided || provided !== apiToken) {
if (!provided) {
return res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
}
next();
try {
// Resolve the token to its owning user (the user whose cfg:API_TOKEN
// matches). An empty token is never a valid key — otherwise every user who
// has saved their settings once (which stores API_TOKEN as '') would be
// matched by an empty header.
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 != ''`,
[CONFIG_PREFIX + 'API_TOKEN', provided]
);
// Fail closed on an ambiguous token: if two users somehow share a value we
// must not silently pick one of them and hand the caller that user's data.
// (Saving a token that another user already uses is rejected in the UI.)
if (matches.length !== 1) {
if (matches.length > 1) {
console.error('API 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;
// Warm this user's cfg rows: config.get() is synchronous and reads from
// the per-user cache, so without this an API request could see the user
// as unconfigured (e.g. no Ollama key) purely because nothing had loaded
// their rows yet in this process.
await config.ensureLoaded(user.id);
// Run the remainder of the request inside this user's context so
// currentUserId() / config.get() / the scoped helpers all resolve here.
userContext.run(user, next);
} catch (err) {
console.error('API auth error:', err);
res.status(401).json({ error: 'Ungültiger oder fehlender API-Key (Header: X-API-Key).' });
}
});
// --- helpers -------------------------------------------------------
async function getApplication(id) {
return dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
return dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
}
// --- System --------------------------------------------------------
@@ -60,12 +112,17 @@ function createExternalApi(deps) {
// --- Applications --------------------------------------------------
router.get('/applications', async (req, res) => {
try {
const { month, year, status, art, search } = req.query;
const { month, year, status, art, search, label } = req.query;
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const where = [];
const params = [];
const where = ['user_id = ?'];
const params = [uid()];
if (label && LABEL_OPTIONS.includes(label)) {
// labels is a JSON array string; match the quoted label token.
where.push('labels LIKE ?');
params.push(`%"${label}"%`);
}
if (month) {
where.push('strftime("%m", datum) = ?');
params.push(String(month).padStart(2, '0'));
@@ -88,12 +145,13 @@ function createExternalApi(deps) {
params.push(term, term);
}
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
const clause = `WHERE ${where.join(' AND ')}`;
const applications = await dbAll(
`SELECT * FROM bewerbungen ${clause} ORDER BY datum DESC, created_at DESC LIMIT ? OFFSET ?`,
[...params, limit, offset]
);
await attachVerlauf(applications);
applications.forEach(withLabels);
res.json(applications);
} catch (error) {
console.error('API list applications error:', error);
@@ -124,10 +182,11 @@ function createExternalApi(deps) {
const result = await dbRun(
`INSERT INTO bewerbungen
(datum, firma, stelle, art, status, notizen, interne_notizen, ort,
stellenbeschreibung, quelle_url, llm_notizen, generierung_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
(user_id, datum, firma, stelle, art, status, notizen, interne_notizen, ort,
stellenbeschreibung, quelle_url, llm_notizen, labels, generierung_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
[
uid(),
datum,
sanitizeInput(firma),
sanitizeInput(stelle),
@@ -139,18 +198,19 @@ function createExternalApi(deps) {
sanitizeInput(b.stellenbeschreibung || ''),
sanitizeInput(b.quelle_url || ''),
sanitizeInput(b.llm_notizen || ''),
serializeLabels(b.labels),
]
);
if (b.status && String(b.status).trim()) {
await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')]
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
[uid(), result.lastID, datum, sanitizeInput(b.status), sanitizeInput(b.kommentar || '')]
);
}
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
res.json({ success: true, application });
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
res.json({ success: true, application: withLabels(application) });
} catch (error) {
console.error('API create application error:', error);
res.status(500).json({ error: 'Serverfehler' });
@@ -162,7 +222,7 @@ function createExternalApi(deps) {
const application = await getApplication(req.params.id);
if (!application) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
await attachVerlauf([application]);
res.json(application);
res.json(withLabels(application));
} catch (error) {
console.error('API get application error:', error);
res.status(500).json({ error: 'Serverfehler' });
@@ -184,8 +244,8 @@ function createExternalApi(deps) {
`UPDATE bewerbungen SET
datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?,
interne_notizen = ?, ort = ?, stellenbeschreibung = ?, quelle_url = ?,
llm_notizen = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
llm_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`,
[
b.datum,
sanitizeInput(b.firma),
@@ -198,12 +258,16 @@ function createExternalApi(deps) {
sanitizeInput(b.stellenbeschreibung ?? existing.stellenbeschreibung),
sanitizeInput(b.quelle_url ?? existing.quelle_url),
sanitizeInput(b.llm_notizen ?? existing.llm_notizen),
Object.prototype.hasOwnProperty.call(b, 'labels')
? serializeLabels(b.labels)
: (existing.labels || '[]'),
id,
uid(),
]
);
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
res.json({ success: true, application });
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
res.json({ success: true, application: withLabels(application) });
} catch (error) {
console.error('API update application error:', error);
res.status(500).json({ error: 'Serverfehler' });
@@ -216,8 +280,8 @@ function createExternalApi(deps) {
const existing = await getApplication(id);
if (!existing) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]);
await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]);
await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
await dbRun('DELETE FROM bewerbungen WHERE id = ? AND user_id = ?', [id, uid()]);
res.json({ success: true });
} catch (error) {
console.error('API delete application error:', error);
@@ -231,8 +295,8 @@ function createExternalApi(deps) {
const { id } = req.params;
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const verlauf = await dbAll(
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
[id]
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? AND user_id = ? ORDER BY date(datum) ASC, id ASC',
[id, uid()]
);
res.json(verlauf);
} catch (error) {
@@ -253,12 +317,12 @@ function createExternalApi(deps) {
const day = datum || new Date().toISOString().split('T')[0];
const result = await dbRun(
'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)',
[id, day, status, sanitizeInput(kommentar || '')]
'INSERT INTO status_verlauf (user_id, bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?, ?)',
[uid(), id, day, status, sanitizeInput(kommentar || '')]
);
await syncCurrentStatus(id);
const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ?', [result.lastID]);
const entry = await dbGet('SELECT * FROM status_verlauf WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
res.json(entry);
} catch (error) {
console.error('API add timeline error:', error);
@@ -270,12 +334,12 @@ function createExternalApi(deps) {
try {
const { id, eintragId } = req.params;
const entry = await dbGet(
'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ?',
[eintragId, id]
'SELECT id FROM status_verlauf WHERE id = ? AND bewerbung_id = ? AND user_id = ?',
[eintragId, id, uid()]
);
if (!entry) return res.status(404).json({ error: 'Verlaufseintrag nicht gefunden' });
await dbRun('DELETE FROM status_verlauf WHERE id = ?', [eintragId]);
await dbRun('DELETE FROM status_verlauf WHERE id = ? AND user_id = ?', [eintragId, uid()]);
await syncCurrentStatus(id);
res.json({ success: true });
} catch (error) {
@@ -290,8 +354,8 @@ function createExternalApi(deps) {
const { id } = req.params;
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const anhaenge = await dbAll(
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
[id]
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
[id, uid()]
);
res.json(anhaenge);
} catch (error) {
@@ -304,12 +368,12 @@ function createExternalApi(deps) {
try {
const { id, attachmentId } = req.params;
const anhang = await dbGet(
'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?',
[attachmentId, id]
'SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ? AND user_id = ?',
[attachmentId, id, uid()]
);
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
const file = path.join(anhaengeDir, anhang.pfad);
const file = path.join(userStorageDir(anhaengeDir), anhang.pfad);
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
res.download(file, anhang.dateiname || anhang.name || path.basename(anhang.pfad));
@@ -326,14 +390,14 @@ function createExternalApi(deps) {
if (!(await getApplication(id))) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const emails = await dbAll(
'SELECT * FROM emails WHERE bewerbung_id = ? ORDER BY datetime(email_date) ASC, id ASC',
[id]
'SELECT * FROM emails WHERE bewerbung_id = ? AND user_id = ? ORDER BY datetime(email_date) ASC, id ASC',
[id, uid()]
);
if (emails.length) {
const eIds = emails.map((e) => e.id);
const atts = await dbAll(
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE email_id IN (${eIds.map(() => '?').join(',')})`,
eIds
`SELECT id, email_id, name, mime FROM email_anhaenge WHERE user_id = ? AND email_id IN (${eIds.map(() => '?').join(',')})`,
[uid(), ...eIds]
);
const byEmail = {};
atts.forEach((a) => { (byEmail[a.email_id] = byEmail[a.email_id] || []).push(a); });
@@ -350,12 +414,12 @@ function createExternalApi(deps) {
try {
const { emailId, attachmentId } = req.params;
const anhang = await dbGet(
'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ?',
[attachmentId, emailId]
'SELECT * FROM email_anhaenge WHERE id = ? AND email_id = ? AND user_id = ?',
[attachmentId, emailId, uid()]
);
if (!anhang) return res.status(404).json({ error: 'Anhang nicht gefunden' });
const file = path.join(emailAnhaengeDir, anhang.pfad);
const file = path.join(userStorageDir(emailAnhaengeDir), anhang.pfad);
if (!fs.existsSync(file)) return res.status(404).json({ error: 'Datei nicht auf Festplatte vorhanden' });
res.download(file, anhang.name || path.basename(anhang.pfad));
@@ -373,27 +437,36 @@ function createExternalApi(deps) {
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
if (typeof (req.body || {}).llm_notizen !== 'undefined') {
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]);
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ? AND user_id = ?', [req.body.llm_notizen || '', id, uid()]);
}
// Drop existing generated attachments + their files before re-generating.
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
const dir = userStorageDir(anhaengeDir);
for (const a of alte) {
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
fs.promises.unlink(path.join(dir, a.pfad)).catch(() => {});
}
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]);
await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ? AND user_id = ?', [id, uid()]);
await dbRun(
"UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?",
[id]
"UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ? AND user_id = ?",
[id, uid()]
);
// Optional: IDs of static attachments (basis_anhaenge) to enclose; none
// by default. Also reflected in the cover letter's "Anlagen" list.
// Optional: IDs of static attachments (basis_anhaenge) to enclose. Omitted
// 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)
? req.body.anlagen.map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n))
: [];
runGeneration(id, { anlagenIds });
res.status(202).json({ success: true });
: config.anlagenDefaultIds();
// Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or
// both). Omitted / empty falls back to the user's preselection from the
// 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()]);
runGeneration(id, { anlagenIds, dokumente });
res.status(202).json({ success: true, dokumente });
} catch (error) {
console.error('API generate error:', error);
res.status(500).json({ error: 'Serverfehler' });
@@ -404,14 +477,14 @@ function createExternalApi(deps) {
try {
const { id } = req.params;
const bewerbung = await dbGet(
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?',
[id]
'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ? AND user_id = ?',
[id, uid()]
);
if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
const anhaenge = await dbAll(
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC',
[id]
'SELECT id, bewerbung_id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? AND user_id = ? ORDER BY id ASC',
[id, uid()]
);
res.json({ status: bewerbung.generierung_status, fehler: bewerbung.generierung_fehler, anhaenge });
} catch (error) {
@@ -423,22 +496,40 @@ function createExternalApi(deps) {
// --- Settings ------------------------------------------------------
router.get('/settings', async (req, res) => {
try {
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
res.json(settings);
// A user who never saved their personal details has no settings row; that
// is a valid state, so answer with an empty object instead of no body.
const settings = await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]);
res.json(settings || {});
} catch (error) {
console.error('API get settings error:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
// Personal details, same set of fields the web UI writes (Persönliche Angaben).
// Only the keys present in the body are changed; omitted keys keep their value,
// so a client can patch a single field without wiping the rest.
const SETTINGS_FIELDS = ['name', 'adresse', 'kundennummer', 'email', 'telefon', 'ort', 'webseite', 'geburtsdatum'];
router.put('/settings', async (req, res) => {
try {
const { name, adresse, kundennummer } = req.body || {};
const b = req.body || {};
const keys = SETTINGS_FIELDS.filter((k) => typeof b[k] !== 'undefined');
if (!keys.length) {
return res.status(400).json({ error: `Mindestens eines dieser Felder erforderlich: ${SETTINGS_FIELDS.join(', ')}` });
}
// Upsert, not UPDATE: a user without a settings row would otherwise match
// zero rows and the write would be silently dropped.
const werte = keys.map((k) => sanitizeInput(String(b[k] ?? '')));
await dbRun(
'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1',
[sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)]
`INSERT INTO settings (user_id, ${keys.join(', ')})
VALUES (?, ${keys.map(() => '?').join(', ')})
ON CONFLICT(user_id) DO UPDATE SET
${keys.map((k) => `${k} = excluded.${k}`).join(', ')}`,
[uid(), ...werte]
);
res.json({ success: true });
const row = await dbGet('SELECT * FROM settings WHERE user_id = ?', [uid()]);
res.json({ success: true, settings: row || {} });
} catch (error) {
console.error('API save settings error:', error);
res.status(500).json({ error: 'Serverfehler' });
@@ -448,17 +539,19 @@ function createExternalApi(deps) {
// --- Statistics & export -------------------------------------------
router.get('/statistics', async (req, res) => {
try {
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
const byArt = await dbAll(`
SELECT art, COUNT(*) as count FROM bewerbungen
WHERE art IS NOT NULL AND art != ''
GROUP BY art ORDER BY count DESC
`);
const byStatus = await dbAll(`
SELECT status, COUNT(*) as count FROM bewerbungen
WHERE status IS NOT NULL AND status != ''
GROUP BY status ORDER BY count DESC
`);
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen WHERE user_id = ?', [uid()]);
const byArt = await dbAll(
`SELECT art, COUNT(*) as count FROM bewerbungen
WHERE user_id = ? AND art IS NOT NULL AND art != ''
GROUP BY art ORDER BY count DESC`,
[uid()]
);
const byStatus = await dbAll(
`SELECT status, COUNT(*) as count FROM bewerbungen
WHERE user_id = ? AND status IS NOT NULL AND status != ''
GROUP BY status ORDER BY count DESC`,
[uid()]
);
res.json({
total: totalCount ? totalCount.count : 0,
byArt,
@@ -473,16 +566,16 @@ function createExternalApi(deps) {
router.get('/export', async (req, res) => {
try {
const { month, year } = req.query;
let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC';
const params = [];
let query = 'SELECT * FROM bewerbungen WHERE user_id = ? ORDER BY datum DESC';
const params = [uid()];
if (month && year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
params.push(String(month).padStart(2, '0'), String(year));
} else if (month) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? ORDER BY datum DESC';
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%m", datum) = ? ORDER BY datum DESC';
params.push(String(month).padStart(2, '0'));
} else if (year) {
query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC';
query = 'SELECT * FROM bewerbungen WHERE user_id = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC';
params.push(String(year));
}
@@ -500,7 +593,7 @@ function createExternalApi(deps) {
// --- Templates -----------------------------------------------------
router.get('/templates', async (req, res) => {
try {
const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
const docs = await dbAll('SELECT * FROM basis_dokumente WHERE user_id = ? ORDER BY id ASC', [uid()]);
res.json(docs);
} catch (error) {
console.error('API list templates error:', error);
@@ -509,13 +602,16 @@ function createExternalApi(deps) {
});
// --- Job offers (ingested by third-party software) -----------------
// POST upserts by (quelle, external_id): re-sending the same offer updates
// it instead of creating a duplicate. `quelle` defaults to "drittanbieter".
// POST upserts by (user_id, quelle, external_id): re-sending the same offer
// updates it instead of creating a duplicate. `quelle` defaults to
// "drittanbieter".
router.get('/joboffers', async (req, res) => {
try {
const rows = await dbAll(
`SELECT * FROM jobangebote ORDER BY created_at DESC, id DESC`
'SELECT * FROM jobangebote WHERE user_id = ? ORDER BY created_at DESC, id DESC',
[uid()]
);
rows.forEach(withLabels);
res.json(rows);
} catch (error) {
console.error('API list job offers error:', error);
@@ -529,7 +625,8 @@ function createExternalApi(deps) {
router.get('/joboffers/blacklist', async (req, res) => {
try {
const rows = await dbAll(
'SELECT * FROM jobangebote_blacklist ORDER BY created_at DESC, id DESC'
'SELECT * FROM jobangebote_blacklist WHERE user_id = ? ORDER BY created_at DESC, id DESC',
[uid()]
);
res.json(rows);
} catch (error) {
@@ -556,10 +653,10 @@ function createExternalApi(deps) {
}
const cols = blacklist.COLUMNS;
const result = await dbRun(
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
`INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${cols.map(() => '?').join(', ')})`,
[uid(), ...cols.map((c) => (entry[c] === undefined ? null : entry[c]))]
);
const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ?', [result.lastID]);
const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
res.status(201).json({ success: true, entry: row });
} catch (error) {
console.error('API create blacklist entry error:', error);
@@ -569,9 +666,9 @@ function createExternalApi(deps) {
router.delete('/joboffers/blacklist/:id', async (req, res) => {
try {
const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (!row) return res.status(404).json({ error: 'Blacklist-Eintrag nicht gefunden' });
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
res.json({ success: true });
} catch (error) {
console.error('API delete blacklist entry error:', error);
@@ -581,9 +678,9 @@ function createExternalApi(deps) {
router.get('/joboffers/:id', async (req, res) => {
try {
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
res.json(row);
res.json(withLabels(row));
} catch (error) {
console.error('API get job offer error:', error);
res.status(500).json({ error: 'Serverfehler' });
@@ -598,7 +695,7 @@ function createExternalApi(deps) {
}
// Reject blacklisted offers outright — they must never reappear.
const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist');
const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [uid()]);
const blocked = blacklist.matchBlacklist(blacklistRows, b);
if (blocked) {
return res.status(409).json({
@@ -614,7 +711,12 @@ function createExternalApi(deps) {
const urlNorm = blacklist.normalizeUrl(b.quelle_url || '') || null;
const anzeigeDatum = sanitizeInput(b.anzeige_datum || '');
const kontaktEmail = sanitizeInput(b.kontakt_email || '');
// Column order matches the UPDATE/INSERT statements below (url_norm last).
// Company slug: stored as supplied by the client; derived from firma only
// when omitted. This is the key the blacklist matches on (see below).
const firmaSlug = b.firma_slug != null && String(b.firma_slug).trim() !== ''
? sanitizeInput(String(b.firma_slug))
: (blacklist.firmaSlug(b.firma) || null);
// Column order matches the UPDATE/INSERT statements below (firma_slug last).
const fields = [
sanitizeInput(b.firma),
sanitizeInput(b.stelle),
@@ -628,22 +730,25 @@ function createExternalApi(deps) {
anzeigeDatum,
kontaktEmail,
sanitizeInput(b.status || 'offen'),
serializeLabels(b.labels),
urlNorm,
firmaSlug,
];
// De-dup: prefer a (quelle, external_id) match, else the same normalized
// URL — so the same posting never lands twice, even with a new id.
// De-dup: prefer a (user_id, quelle, external_id) match, else the same
// normalized URL — so the same posting never lands twice, even with a
// new id.
let existing = null;
if (externalId) {
existing = await dbGet(
'SELECT id FROM jobangebote WHERE quelle = ? AND external_id = ?',
[quelle, externalId]
'SELECT id FROM jobangebote WHERE user_id = ? AND quelle = ? AND external_id = ?',
[uid(), quelle, externalId]
);
}
if (!existing && urlNorm) {
existing = await dbGet(
'SELECT id FROM jobangebote WHERE url_norm = ? ORDER BY id ASC LIMIT 1',
[urlNorm]
'SELECT id FROM jobangebote WHERE user_id = ? AND url_norm = ? ORDER BY id ASC LIMIT 1',
[uid(), urlNorm]
);
}
@@ -651,21 +756,21 @@ function createExternalApi(deps) {
await dbRun(
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?,
url_norm = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[...fields, existing.id]
labels = ?, url_norm = ?, firma_slug = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?`,
[...fields, existing.id, uid()]
);
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [existing.id]);
return res.json({ success: true, action: 'updated', joboffer: row });
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [existing.id, uid()]);
return res.json({ success: true, action: 'updated', joboffer: withLabels(row) });
}
const result = await dbRun(
`INSERT INTO jobangebote
(external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, url_norm)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[externalId, quelle, ...fields]
(user_id, external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, labels, url_norm, firma_slug)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[uid(), externalId, quelle, ...fields]
);
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [result.lastID]);
res.status(201).json({ success: true, action: 'created', joboffer: row });
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [result.lastID, uid()]);
res.status(201).json({ success: true, action: 'created', joboffer: withLabels(row) });
} catch (error) {
console.error('API create job offer error:', error);
res.status(500).json({ error: 'Serverfehler' });
@@ -676,24 +781,24 @@ function createExternalApi(deps) {
// be ingested again; pass ?blacklist=false to hard-delete without blocking.
router.delete('/joboffers/:id', async (req, res) => {
try {
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
const skipBlacklist = req.query.blacklist === 'false' || req.query.blacklist === '0';
let blacklisted = false;
if (!skipBlacklist) {
const rows = await dbAll('SELECT * FROM jobangebote_blacklist');
const rows = await dbAll('SELECT * FROM jobangebote_blacklist WHERE user_id = ?', [uid()]);
if (!blacklist.matchBlacklist(rows, row)) {
const cols = blacklist.COLUMNS;
const entry = blacklist.buildAutoEntry(row, 'Jobangebot gelöscht (REST-API)');
await dbRun(
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
`INSERT INTO jobangebote_blacklist (user_id, ${cols.join(', ')}) VALUES (?, ${cols.map(() => '?').join(', ')})`,
[uid(), ...cols.map((c) => (entry[c] === undefined ? null : entry[c]))]
);
}
blacklisted = true;
}
await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]);
await dbRun('DELETE FROM jobangebote WHERE id = ? AND user_id = ?', [req.params.id, uid()]);
res.json({ success: true, blacklisted });
} catch (error) {
console.error('API delete job offer error:', error);
+81 -8
View File
@@ -8,9 +8,10 @@
// relevant to its typ are set:
// 'url' -> url_norm (exact normalized URL)
// 'domain' -> domain (whole host, e.g. a job board)
// 'firma' -> firma_norm (block a company entirely)
// 'firma_stelle' -> firma_norm + stelle_norm (+ optional ort_norm)
// 'auto' -> url_norm / quelle+external_id / firma_norm+stelle_norm
// 'firma' -> firma_slug (block a company entirely; slug is a
// legal-form/umlaut-robust key, falls back to firma_norm)
// 'firma_stelle' -> firma_slug + stelle_norm (+ optional ort_norm)
// 'auto' -> url_norm / quelle+external_id / firma_slug+stelle_norm
// (created on delete; matches on ANY stored signature)
const TYPES = ['url', 'domain', 'firma', 'firma_stelle', 'auto'];
@@ -72,6 +73,61 @@ function normalizeUrl(raw) {
return host + pathname + qs;
}
// Legal-form / entity suffixes stripped from a company name before slugging,
// so "Bosch GmbH", "Bosch AG", "Bosch GmbH & Co. KG" and "Bosch" all collapse
// to the same slug. Matched as whole space-separated tokens at the end.
const LEGAL_FORM_TOKENS = new Set([
'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',
]);
// A robust matching key for a company name. Beyond normText it transliterates
// German umlauts (ä→ae …), strips diacritics, drops trailing legal-form tokens
// and joins the rest with hyphens. This is what the blacklist compares on so
// that entity form, spacing and umlaut spelling no longer cause misses.
function firmaSlug(s) {
let t = unescapeInput(s)
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
.replace(/\((?:[mwdfax](?:\s*\/\s*[mwdfax])*)\)/gi, ' ')
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!t) return '';
const words = t.split(' ');
const kept = words.slice();
// Drop legal-form tokens from the end (e.g. "gmbh & co kg" → "gmbh co kg"),
// but never strip everything away.
while (kept.length > 1 && LEGAL_FORM_TOKENS.has(kept[kept.length - 1])) {
kept.pop();
}
return (kept.length ? kept : words).join('-');
}
// Do two company slugs refer to the SAME employer? True when they are equal, or
// when the shorter one's hyphen tokens are a leading prefix of the longer one's.
// The prefix rule collapses "short name" vs "full legal name" spellings of one
// firm — e.g. "it-problemloeser" vs "it-problemloeser-verwaltungs-und-handels",
// or "stadtwerke-berlin" vs "stadtwerke-berlin-netz". To avoid merging distinct
// firms that merely share a first word ("meyer-it" vs "meyer-logistik"), a pure
// prefix match requires the shorter slug to carry at least two tokens; a
// single-token slug only matches an identical one.
function sameCompany(a, b) {
if (!a || !b) return false;
if (a === b) return true;
const ta = String(a).split('-').filter(Boolean);
const tb = String(b).split('-').filter(Boolean);
const [short, long] = ta.length <= tb.length ? [ta, tb] : [tb, ta];
if (short.length < 2) return false; // single distinctive token → exact only
for (let i = 0; i < short.length; i++) {
if (short[i] !== long[i]) return false;
}
return true;
}
// Normalise a company / job-title string for fuzzy identity matching:
// unescape, lowercase, drop gender markers like "(m/w/d)", collapse anything
// non-alphanumeric to single spaces.
@@ -95,11 +151,23 @@ function offerSignature(offer) {
external_id: o.external_id != null && String(o.external_id).trim() !== ''
? String(o.external_id).trim() : null,
firma_norm: normText(o.firma),
// Prefer a client-supplied slug (offers carry firma_slug); else derive it.
firma_slug: o.firma_slug != null && String(o.firma_slug).trim() !== ''
? String(o.firma_slug).trim() : firmaSlug(o.firma),
stelle_norm: normText(o.stelle),
ort_norm: normText(o.ort),
};
}
// Company identity match: prefer the robust slug (equal or prefix — see
// sameCompany, which absorbs spelling / legal-form / umlaut variants of one
// employer), fall back to firma_norm for legacy rows written before firma_slug
// existed (or not yet backfilled).
function firmaMatch(row, sig) {
if (row.firma_slug && sig.firma_slug) return sameCompany(row.firma_slug, sig.firma_slug);
return !!row.firma_norm && row.firma_norm === sig.firma_norm;
}
// Does one blacklist row block the given signature?
function rowMatches(row, sig) {
switch (row.typ) {
@@ -108,9 +176,9 @@ function rowMatches(row, sig) {
case 'domain':
return !!row.domain && row.domain === sig.domain;
case 'firma':
return !!row.firma_norm && row.firma_norm === sig.firma_norm;
return firmaMatch(row, sig);
case 'firma_stelle':
return !!row.firma_norm && row.firma_norm === sig.firma_norm
return firmaMatch(row, sig)
&& !!row.stelle_norm && row.stelle_norm === sig.stelle_norm
&& (!row.ort_norm || row.ort_norm === sig.ort_norm);
case 'auto':
@@ -118,8 +186,8 @@ function rowMatches(row, sig) {
if (row.url_norm && sig.url_norm && row.url_norm === sig.url_norm) return true;
if (row.quelle && row.external_id && sig.external_id
&& row.quelle === sig.quelle && row.external_id === sig.external_id) return true;
if (row.firma_norm && row.stelle_norm
&& row.firma_norm === sig.firma_norm && row.stelle_norm === sig.stelle_norm
if ((row.firma_slug || row.firma_norm) && row.stelle_norm
&& firmaMatch(row, sig) && row.stelle_norm === sig.stelle_norm
&& (!row.ort_norm || row.ort_norm === sig.ort_norm)) return true;
return false;
}
@@ -137,7 +205,7 @@ function matchBlacklist(rows, offer) {
// Column set for INSERT — keeps server.js and api.js in lock-step.
const COLUMNS = [
'typ', 'url_norm', 'domain', 'quelle', 'external_id',
'firma_norm', 'stelle_norm', 'ort_norm', 'firma', 'stelle', 'quelle_url', 'grund',
'firma_norm', 'firma_slug', 'stelle_norm', 'ort_norm', 'firma', 'stelle', 'quelle_url', 'grund',
];
function emptyEntry() {
@@ -170,6 +238,7 @@ function buildManualEntry(input) {
const w = wert || inp.firma || '';
e.firma = w || null;
e.firma_norm = normText(w);
e.firma_slug = firmaSlug(w) || null;
if (!e.firma_norm) return null;
} else if (typ === 'firma_stelle') {
const f = inp.firma || '';
@@ -177,6 +246,7 @@ function buildManualEntry(input) {
e.firma = f || null;
e.stelle = s || null;
e.firma_norm = normText(f);
e.firma_slug = firmaSlug(f) || null;
e.stelle_norm = normText(s);
e.ort_norm = inp.ort ? normText(inp.ort) : null;
if (!e.firma_norm || !e.stelle_norm) return null;
@@ -196,6 +266,7 @@ function buildAutoEntry(offer, grund) {
e.quelle = sig.external_id ? sig.quelle : null;
e.external_id = sig.external_id || null;
e.firma_norm = sig.firma_norm || null;
e.firma_slug = sig.firma_slug || null;
e.stelle_norm = sig.stelle_norm || null;
e.ort_norm = sig.ort_norm || null;
e.firma = offer && offer.firma ? offer.firma : null;
@@ -210,6 +281,8 @@ module.exports = {
normalizeUrl,
urlDomain,
normText,
firmaSlug,
sameCompany,
offerSignature,
rowMatches,
matchBlacklist,
+38 -9
View File
@@ -4,7 +4,7 @@
// creates / updates / deletes events (PUT / DELETE with iCalendar VEVENTs).
// Auth is HTTP Basic over HTTPS using the same mail account as lib/mailer.
//
// Config (env):
// Config (DB, editable via /einstellungen — see lib/config.js):
// CALDAV_URL full URL of the calendar collection (must end with /)
// MAIL_USER login (shared with mail)
// MAIL_PASSWORD password (shared with mail)
@@ -14,15 +14,18 @@
// Europe/Berlin. Wall-clock input from the UI is converted with wallToUtc().
const crypto = require('crypto');
const config = require('./config');
const TZ = 'Europe/Berlin';
// Read fresh on every call so an edit on the /einstellungen page takes effect
// immediately (values live in the DB now, not in process.env/.env).
function cfg() {
return {
url: (process.env.CALDAV_URL || '').trim(),
user: process.env.MAIL_USER || '',
pass: process.env.MAIL_PASSWORD || '',
alarmMin: Number(process.env.CALDAV_ALARM_MIN) || 60,
url: (config.get('CALDAV_URL') || '').trim(),
user: config.get('MAIL_USER') || '',
pass: config.get('MAIL_PASSWORD') || '',
alarmMin: Number(config.get('CALDAV_ALARM_MIN')) || 60,
};
}
@@ -73,6 +76,16 @@ function localInputToUtc(dateStr, timeStr) {
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".
function toIcsUtc(date) {
return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate())
@@ -122,6 +135,21 @@ function unescapeText(s) {
.replace(/\\\\/g, '\\');
}
// Some clients store SUMMARY/LOCATION/DESCRIPTION with HTML entities baked in
// (e.g. "Vorstellungsgespr&#228;ch &#8211; dot.haus"). iCal text is plain, so
// decode them back to real characters before we hand the value on.
function decodeHtmlEntities(s) {
return String(s == null ? '' : s)
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&');
}
// Fold a content line to <=75 octets per RFC 5545 (CRLF + single space).
function fold(line) {
if (Buffer.byteLength(line, 'utf8') <= 75) return line;
@@ -142,7 +170,7 @@ function buildICS(evt) {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Bewerbungs-Tracker//Kalender//DE',
'PRODID:-//NextJobs//Kalender//DE',
'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
'UID:' + uid,
@@ -204,9 +232,9 @@ function parseVEvent(ics) {
paramParts.forEach((p) => { const [k, v] = p.split('='); if (k) params[k.toUpperCase()] = v; });
const key = name.toUpperCase();
if (key === 'UID') ev.uid = value.trim();
else if (key === 'SUMMARY') ev.summary = unescapeText(value);
else if (key === 'LOCATION') ev.location = unescapeText(value);
else if (key === 'DESCRIPTION') ev.description = unescapeText(value);
else if (key === 'SUMMARY') ev.summary = decodeHtmlEntities(unescapeText(value));
else if (key === 'LOCATION') ev.location = decodeHtmlEntities(unescapeText(value));
else if (key === 'DESCRIPTION') ev.description = decodeHtmlEntities(unescapeText(value));
else if (key === 'DTSTART') { const r = icsToDate(value, params); ev.start = r.date; ev.allDay = r.allDay; }
else if (key === 'DTEND') { const r = icsToDate(value, params); ev.end = r.date; }
}
@@ -317,6 +345,7 @@ module.exports = {
collectionUrl,
wallToUtc,
localInputToUtc,
utcToWallInputs,
buildICS,
parseVEvent,
getCtag,
+276
View File
@@ -0,0 +1,276 @@
// Conversational chat over Ollama Cloud with tool calling.
//
// The rest of the app uses the LLM as a one-shot generator (cover letters,
// e-mail replies). This module adds an interactive chat: it streams assistant
// tokens from Ollama (`stream: true`, NDJSON) and lets the assistant look up
// the user's application data on demand via tool calls instead of having every
// application baked into the system prompt (which would be slow and grow with
// the data).
//
// Unlike lib/documents.js, no JSON output schema is enforced — the model just
// replies as text, token by token, so the UI can render progressively.
const promptStore = require('./prompts');
const config = require('./config');
// Firmenrecherche braucht mehr Runden als eine reine Datenbankfrage: Bewerbung
// holen -> web_suche -> ein, zwei Seiten lesen -> antworten. Mit 4 Runden lief
// der Assistent dabei ins Limit, statt zu antworten.
const MAX_TOOL_ROUNDS = 8;
function isConfigured() {
return Boolean(config.ollama().apiKey);
}
// Stream a single chat completion from Ollama. `messages` = [{role, content}]
// in chronological order (system prompt is prepended separately). `tools` is an
// optional Ollama tool-definition array; when the model calls a tool the
// returned `toolCalls` is non-empty and `content` is usually empty. `onToken`
// fires with each incremental text chunk. Returns { content, toolCalls }.
// Aborts cleanly via `signal`.
async function streamChat({ system, messages, tools, onToken, temperature = 0.6, signal }) {
const { host: ollamaHost, model: ollamaModel, apiKey } = config.ollama();
if (!apiKey) throw new Error('OLLAMA_API_KEY ist nicht gesetzt (unter „Einstellungen“ konfigurieren).');
const allMessages = [];
if (system) allMessages.push({ role: 'system', content: system });
for (const m of messages) {
// Preserve tool_calls (assistant) and tool_name (tool result) so Ollama can
// match tool calls to their results across rounds.
const msg = { role: m.role, content: m.content };
if (m.tool_calls) msg.tool_calls = m.tool_calls;
if (m.tool_name) msg.tool_name = m.tool_name;
allMessages.push(msg);
}
const body = {
model: ollamaModel,
stream: true,
options: { temperature },
messages: allMessages,
};
if (tools && tools.length) body.tools = tools;
let res;
try {
res = await fetch(`${ollamaHost}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body),
signal,
});
} catch (err) {
if (err.name === 'AbortError') throw new Error('Abgebrochen.');
throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`);
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Ollama-API antwortete mit ${res.status}: ${text.slice(0, 300)}`);
}
if (!res.body || !res.body.getReader) {
// Node without streaming body support: fall back to buffered response.
const data = await res.json();
const msg = (data && data.message) || {};
const text = (msg.content || '').trim();
if (onToken && text) onToken(text);
return { content: text, toolCalls: normalizeToolCalls(msg.tool_calls) };
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let full = '';
const toolCalls = [];
// Ollama streams one JSON object per line (NDJSON). Accumulate partial
// lines across chunks, then parse each complete line.
const handleLine = (line) => {
line = line.trim();
if (!line) return;
let obj;
try { obj = JSON.parse(line); } catch (e) { return; } // ignore keepalives
const msg = obj && obj.message;
if (!msg) return;
const delta = msg.content;
if (delta) {
full += delta;
if (onToken) onToken(delta);
}
if (msg.tool_calls && msg.tool_calls.length) {
// Ollama emits tool_calls (complete) on the final chunk; capture them.
toolCalls.length = 0;
toolCalls.push(...normalizeToolCalls(msg.tool_calls));
}
};
while (true) {
let chunk;
try {
chunk = await reader.read();
} catch (err) {
if (err.name === 'AbortError') throw new Error('Abgebrochen.');
throw err;
}
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let nl;
while ((nl = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
handleLine(line);
}
}
handleLine(buffer); // flush trailing line
return { content: full.trim(), toolCalls };
}
// Coerce Ollama's tool_calls into a stable shape: [{function:{name, arguments}}]
// where arguments is always a plain object (Ollama sometimes sends a string).
function normalizeToolCalls(raw) {
if (!Array.isArray(raw)) return [];
const out = [];
for (const tc of raw) {
const fn = (tc && tc.function) || {};
let args = fn.arguments;
if (typeof args === 'string') {
try { args = JSON.parse(args); } catch (e) { args = {}; }
}
out.push({ function: { name: fn.name, arguments: args || {} } });
}
return out;
}
// Run a full agentic turn: stream the model's reply; if it calls tools,
// execute them (via `executeTool(name, args) -> any JSON-serialisable value`),
// feed the results back, and loop until the model answers in plain text (or
// MAX_TOOL_ROUNDS is hit). `onToken` streams the final text answer; `onToolCall`
// fires for each tool invocation so the UI can show what's being looked up.
async function runChat({ system, messages, tools, executeTool, onToken, onToolCall, signal, temperature = 0.6 }) {
const convo = messages.map((m) => ({ role: m.role, content: m.content }));
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
const { content, toolCalls } = await streamChat({
system, messages: convo, tools, onToken, temperature, signal,
});
if (!toolCalls || !toolCalls.length) return content;
// Append the assistant's tool-call message verbatim so the model sees its
// own call in the next round, then one tool-result message per call.
convo.push({
role: 'assistant',
content: content || '',
tool_calls: toolCalls,
});
for (const tc of toolCalls) {
const name = tc.function.name;
if (onToolCall) onToolCall(name, tc.function.arguments);
let result;
try {
result = await executeTool(name, tc.function.arguments);
} catch (err) {
result = { error: err.message || String(err) };
}
convo.push({
role: 'tool',
tool_name: name,
content: JSON.stringify(result),
});
}
}
throw new Error('Maximale Anzahl Tool-Aufrufe erreicht.');
}
// Kurzen Thread-Titel aus dem ersten Schlagabtausch formulieren lassen — wie in
// ChatGPT, statt die ersten 60 Zeichen der Frage abzuschneiden ("Ich habe mich
// bei der Hetzner Online GmbH bewor…"). Ein eigener, winziger Aufruf ohne
// Werkzeuge: Der Chat-Systemprompt würde hier nur stören (er will beraten, nicht
// betiteln). Wirft nicht — schlägt die Titelgenerierung fehl, bleibt der Aufrufer
// einfach beim Fallback-Titel.
async function generateTitle({ userText, assistantText, signal }) {
const system =
'Du formulierst kurze Titel für Chat-Verläufe eines Bewerbungs-Assistenten. ' +
'Antworte mit NICHTS außer dem Titel: 2 bis 5 Wörter, Deutsch, keine ' +
'Anführungszeichen, kein Punkt am Ende, keine Einleitung. Benenne das Thema ' +
'konkret (Firma, Stelle oder Anliegen), nicht generisch wie "Frage zur Bewerbung".';
const inhalt =
'Nachricht des Nutzers:\n' + String(userText || '').slice(0, 600) +
'\n\nAntwort des Assistenten:\n' + String(assistantText || '').slice(0, 600) +
'\n\nTitel:';
try {
const { content } = await streamChat({
system,
messages: [{ role: 'user', content: inhalt }],
temperature: 0.2,
signal,
});
// Modelle packen den Titel gern in Anführungszeichen, Sternchen oder eine
// "Titel:"-Zeile — das wieder abräumen.
let titel = String(content || '')
.split('\n').map((z) => z.trim()).filter(Boolean).pop() || '';
titel = titel
.replace(/^\s*(titel|title)\s*:\s*/i, '')
.replace(/^["“„'*#\s]+|["“”'*.\s]+$/g, '')
.trim();
if (titel.length > 60) titel = titel.slice(0, 60).trim();
return titel;
} catch (err) {
return '';
}
}
// Build the German system prompt that grounds the assistant. `context` is the
// lightweight core gathered by the server: { heute, settings, profil }.
// Application data is NOT baked in — the assistant fetches it via tools.
function buildContextPrompt(ctx) {
const name = (ctx && ctx.settings && ctx.settings.name) || 'der Bewerber';
const heute = (ctx && ctx.heute) || '';
const parts = [];
// The assistant's role/tone is editable (Vorlagen page); the sections below
// describe the runtime wiring (tools, date, profile) and stay in code.
parts.push(promptStore.get(ctx && ctx.prompts, 'chat', { name }));
parts.push(
'# Werkzeuge\n' +
'Du hast Werkzeuge, um Bewerbungsdaten nachzuschlagen. Nutze sie, sobald der ' +
'Nutzer nach einer konkreten Bewerbung, Firma, Status, Stellenbeschreibung, ' +
'Korrespondenz oder einem Termin fragt — rufe niemals Daten aus dem Gedächtnis ' +
'ab, die du per Werkzeug holen kannst. Lege mehrere benötigte Werkzeuge in ' +
'einer Runde parallel an. Wenn eine Suche keine Treffer liefert, weise den ' +
'Nutzer darauf hin und frage nach Details (z. B. Link/Stellenbeschreibung).\n\n' +
'Zusätzlich kannst du mit `web_suche` und `web_seite_lesen` im Internet ' +
'recherchieren. Nutze das für alles, was nicht in den Bewerbungsdaten steht: ' +
'Hintergrund zu einer Firma (Produkte, Größe, Standorte, Kultur, aktuelle ' +
'Nachrichten), Gehaltsspannen, Technologien, Vorbereitung auf ein Gespräch. ' +
'Typischer Ablauf bei „Was muss ich über Firma X wissen?": erst die Bewerbung ' +
'per Werkzeug holen (Stelle, Ort, Stellenbeschreibung, quelle_url), dann ' +
'gezielt im Web nachrecherchieren und beides zusammenführen. Recherchiere ' +
'nicht ungefragt bei jeder Frage — nur, wenn aktuelles oder externes Wissen ' +
'die Antwort wirklich besser macht.\n\n' +
'Bei Web-Rechercheergebnissen: Gib nur wieder, was in den Treffern steht, und ' +
'nenne die Quelle als URL. Wenn du etwas nicht belegen kannst, sage es — rate ' +
'nicht. Achte darauf, dass Treffer wirklich die gemeinte Firma betreffen ' +
'(Namensdopplungen, falscher Standort) und weise auf Zweifel hin.'
);
if (heute) {
parts.push('# Heute\n' + 'Heutiges Datum: ' + heute + '. ' +
'Terminangaben aus Werkzeugen sind UTC-Zeitstempel (ISO, endet auf Z). ' +
'Rechne in UTC-Zeiten und leite daraus ab, was "heute", "morgen" usw. ist. ' +
'Zeige dem Nutzer Terminzeiten in seiner lokalen Zeit (Europe/Berlin = UTC+1, ' +
'bzw. UTC+2 während der Sommerzeit).');
}
if (ctx && ctx.profil) {
parts.push('# Dein Profil (Lebenslauf)\n' + ctx.profil +
'\nNutze dies, um Antworten auf den Bewerber zuzuschneiden: Wenn der Nutzer ' +
'nach einer Firma fragt, hebe hervor, welche Anforderungen der Stelle zu ' +
'seinem Profil passen und wo Lücken bestehen.');
}
return parts.join('\n\n');
}
module.exports = {
isConfigured, streamChat, runChat, buildContextPrompt, generateTitle, MAX_TOOL_ROUNDS,
// Dynamic so an edit on /einstellungen is reflected without a restart.
get OLLAMA_MODEL() { return config.ollama().model; },
};
+259
View File
@@ -0,0 +1,259 @@
// Centralized, per-user configuration store.
//
// Multi-user: every setting (Ollama, E-Mail, CalDAV, API-Token) is owned by a
// user. The values live in the SQLite app_state table (key prefixed "cfg:"),
// now keyed by (user_id, key). The /einstellungen page edits the *current* user's
// values; the libs (mailer, caldav, chat, documents) read through config.get() /
// config.ollama() at call time, picking up the current user from the per-request
// context (lib/context.js). An edit therefore takes effect immediately, scoped
// to the user who made it — no restart, no .env file.
//
// On first start of an install that previously used .env (or that still passes
// the values as container env vars), the still-present env values are imported
// into the *admin* user's rows once — see importEnvIntoAdmin(), called from
// server.js at boot. After that the database is the single source of truth.
//
// process.env is deliberately NOT a read fallback: env config belongs to the
// admin, so falling back to it would hand every freshly created user the admin's
// mailbox, calendar, Ollama key and API token. Unset keys resolve to the
// built-in defaults instead, which leaves a new user's settings empty.
const { currentUser } = require('./context');
const DEFAULTS = {
// Ollama Cloud (KI text generation + chat).
OLLAMA_API_KEY: '',
OLLAMA_MODEL: 'glm-5.2:cloud',
OLLAMA_HOST: 'https://ollama.com',
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).
MAIL_HOST: '',
MAIL_SMTP_PORT: '587',
MAIL_IMAP_PORT: '993',
MAIL_USER: '',
MAIL_PASSWORD: '',
MAIL_FROM_NAME: '',
MAIL_FROM: '',
MAIL_IMAP_MAILBOX: 'INBOX',
MAIL_POLL_MS: '180000',
// Bewerbungskalender (CalDAV, z. B. SOGo).
CALDAV_URL: '',
CALDAV_ALARM_MIN: '60',
CALDAV_POLL_MS: '300000',
// REST-API für Drittanbietersoftware (/api/v1, Header X-API-Key).
API_TOKEN: '',
};
// UI metadata for the /einstellungen page: grouped sections with input types.
// `secret` fields render as password inputs with a reveal toggle.
const FIELDS = [
{
titel: 'Ollama Cloud (KI-Generierung + Chat)',
beschreibung: 'Steuerung der KI, die Bewerbungsunterlagen erzeugt und den KI-Chat beantwortet. Ohne API-Schlüssel sind diese Funktionen deaktiviert.',
items: [
{ 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: glm-5.2:cloud' },
{ 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.)' },
],
},
{
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)',
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.',
items: [
{ key: 'MAIL_HOST', label: 'SMTP/IMAP Host' },
{ key: 'MAIL_SMTP_PORT', label: 'SMTP-Port', help: '587 = STARTTLS, 465 = implicit TLS' },
{ key: 'MAIL_IMAP_PORT', label: 'IMAP-Port', help: 'Standard: 993 (implicit TLS)' },
{ key: 'MAIL_USER', label: 'Benutzername (Login)', help: 'Auch CalDAV-Login' },
{ key: 'MAIL_PASSWORD', label: 'Passwort', secret: true, help: 'Auch CalDAV-Passwort' },
{ key: 'MAIL_FROM_NAME', label: 'Absendername' },
{ key: 'MAIL_FROM', label: 'Absenderadresse', help: 'Leer = Benutzername' },
{ key: 'MAIL_IMAP_MAILBOX', label: 'IMAP-Postfach', help: 'Standard: INBOX' },
{ key: 'MAIL_POLL_MS', label: 'Abrufintervall (ms)', help: 'Standard: 180000 (3 Min.) — greift nach Neustart' },
],
},
{
titel: 'Bewerbungskalender (CalDAV)',
beschreibung: 'Voll-URL der Kalender-Sammlung (mit abschließendem /). Authentifizierung läuft über MAIL_USER/MAIL_PASSWORD. Ohne URL sind die Kalender-Funktionen deaktiviert.',
items: [
{ key: 'CALDAV_URL', label: 'Kalender-URL', help: 'z. B. https://mail.example.com/SOGo/dav/name@…/Calendar/XXXX/' },
{ key: 'CALDAV_ALARM_MIN', label: 'Erinnerung (Minuten vor Termin)', help: 'Standard: 60' },
{ key: 'CALDAV_POLL_MS', label: 'Sync-Intervall (ms)', help: 'Standard: 300000 (5 Min.) — greift nach Neustart' },
],
},
{
titel: 'REST-API für Drittanbietersoftware',
beschreibung: 'Ist ein Token gesetzt, ist /api/v1 für diesen Benutzer aktiv und erwartet den Wert im Header „X-API-Key“. Der Token identifiziert den Benutzer: Anfragen sehen und ändern ausschließlich dessen eigene Daten. Ohne Token antwortet die API (bis auf /health) mit 401. Swagger unter /swagger.',
items: [
{
key: 'API_TOKEN',
label: 'API-Token (X-API-Key)',
secret: true,
generate: true,
help: 'Leer = API deaktiviert. Über das Auge einblenden, „Neu generieren“ erzeugt einen zufälligen Token — danach speichern. Ein neuer Token macht den alten sofort ungültig.',
},
],
},
];
const PREFIX = 'cfg:';
// Per-user cache: Map<userId, Object<string,string>>. A user is loaded lazily on
// first access (ensureLoaded) and stays cached for the process lifetime. Edits
// via saveAll() update the cache in place so subsequent reads are consistent.
const cache = new Map(); // userId -> { key: value }
const loaded = new Set(); // userIds whose cfg rows have been read from the DB
let dbAllFn = null;
let dbRunFn = null;
// The built-in default for a key. Deliberately NOT a process.env lookup: env
// values belong to the admin (they are imported into the admin's rows once at
// boot, see importEnvIntoAdmin in lib/migrate-multiuser.js). If get() fell back
// to process.env, every user without their own row would silently inherit the
// admin's credentials — their own mailbox, calendar, Ollama key and API token.
// The defaults below are non-secret standards only (model, host, ports, poll
// intervals); every credential defaults to empty, so a new user starts with
// Ollama/E-Mail/CalDAV/API switched off until they configure their own.
function builtinDefault(key) {
return DEFAULTS[key];
}
// Load one user's cfg rows from the DB into the cache. No-op if already loaded.
async function ensureLoaded(userId) {
if (!userId || loaded.has(userId)) return;
const rows = await dbAllFn('SELECT key, value FROM app_state WHERE user_id = ?', [userId]);
const obj = Object.create(null);
for (const r of rows) obj[r.key.slice(PREFIX.length)] = r.value;
cache.set(userId, obj);
loaded.add(userId);
}
// Drop the cached rows for a user so the next read reloads from the DB. Used
// after a direct DB write outside saveAll() (e.g. the one-time env migration).
function invalidate(userId) {
loaded.delete(userId);
cache.delete(userId);
}
// Synchronous read for the *current user only*. A key the user has not stored
// falls back to the built-in default — never to another user's value and never
// to process.env. A user who has configured nothing therefore reads as "empty"
// (no mail host, no keys), which is exactly what isolates them: mailer/caldav
// isConfigured() turns false and their background jobs stay idle.
//
// Outside a request context (boot) there is no user, so only the defaults apply.
// Callers that need a specific user's values must run inside that user's context
// (see lib/context.js) and must have called ensureLoaded(userId) first.
function get(key) {
const u = currentUser();
const userObj = u ? cache.get(u.id) : null;
if (userObj && userObj[key] !== undefined) return userObj[key];
return builtinDefault(key);
}
// All keys with their effective values for the current user — used by the
// settings UI. Must be called within a request context.
function getAll() {
const out = {};
for (const key of Object.keys(DEFAULTS)) out[key] = get(key);
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.
function ollama() {
return {
host: (get('OLLAMA_HOST') || 'https://ollama.com').replace(/\/+$/, ''),
model: get('OLLAMA_MODEL') || 'glm-5.2:cloud',
timeoutMs: Number(get('OLLAMA_TIMEOUT_MS')) || 300000,
apiKey: get('OLLAMA_API_KEY') || '',
};
}
// Wire up DB helpers. The one-time .env migration now runs per-admin at boot
// from server.js (it needs the admin user id); this init only stores the fns.
async function init({ dbAll, dbRun }) {
dbAllFn = dbAll;
dbRunFn = dbRun;
}
// Persist every key for the current user (writes all rows, including empty
// strings, so a cleared field is stored as empty and no longer falls back to
// env/default). Must be called within a request context.
async function saveAll(values) {
if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.');
const u = currentUser();
if (!u) throw new Error('Kein Benutzerkontext für Konfigurationsspeicherung.');
const obj = cache.get(u.id) || Object.create(null);
for (const key of Object.keys(DEFAULTS)) {
const v = values && values[key] != null ? String(values[key]) : '';
await dbRunFn(
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
[u.id, PREFIX + key, v]
);
obj[key] = v;
}
cache.set(u.id, obj);
loaded.add(u.id);
}
// Write a single key for an explicit user (used by the one-time env migration,
// which runs outside a request context). Updates the cache if loaded.
async function setForUser(userId, key, value) {
if (!dbRunFn) throw new Error('Konfigurationsspeicher nicht initialisiert.');
await dbRunFn(
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
[userId, PREFIX + key, String(value)]
);
if (loaded.has(userId)) {
const obj = cache.get(userId) || Object.create(null);
obj[key] = String(value);
cache.set(userId, obj);
}
}
module.exports = {
DEFAULTS, FIELDS, PREFIX,
get, getAll, ollama, anlagenDefaultIds, init, saveAll, setForUser, ensureLoaded, invalidate,
};
+32
View File
@@ -0,0 +1,32 @@
// Per-request user context.
//
// The web UI authenticates a user via a signed session cookie (see the
// /login flow + auth middleware in server.js). The middleware stores the
// resolved user object in an AsyncLocalStorage, so any code running during the
// request — including the libs (config, mailer, caldav, chat) called deep in
// the stack — can read the *current user* without threading it through every
// function signature. Background jobs (IMAP poll, CalDAV sync) set the same
// context per user while iterating, so each user's config/data is used in turn.
//
// This is the keystone of the multi-tenant split: config.get() reads the current
// user's cfg rows, and the per-user query helpers below filter every SELECT/INSERT
// by the current user, guaranteeing isolation between users.
const { AsyncLocalStorage } = require('async_hooks');
const userContext = new AsyncLocalStorage();
// The current user object ({ id, username, is_admin }) or null outside a request
// (e.g. during boot). Anything that needs the user id must call this and decide
// how to behave when it is absent.
function currentUser() {
return userContext.getStore() || null;
}
// Convenience: the current user's id, or null when no user is set.
function currentUserId() {
const u = currentUser();
return u ? u.id : null;
}
module.exports = { userContext, currentUser, currentUserId };
+375
View File
@@ -0,0 +1,375 @@
// Editable design settings for the generated application documents.
//
// 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.
//
// Three layouts ship today:
//
// sidebar — the classic: neutral greys, tinted sidebar, DIN-5008 letter. The
// accent is a single restrained colour; hierarchy comes from weight
// and tracking. Right for almost every office application.
//
// social — a playful media-kit look (rounded cards, pills, hearts, a script
// headline, a full-page tinted background). Right for creative,
// 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
// 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
// works when an HR department prints it in greyscale.
// Accent colours. In `sidebar` the accent is the *only* colour; in `social` and
// `sunny` the whole page palette (bands, cards, pills) is derived from it, so
// those layouts work in berry as well as in deep blue.
const AKZENTE = [
{ key: 'anthrazit', label: 'Anthrazit', rgb: [23, 23, 23] },
{ key: 'tiefblau', label: 'Tiefblau', rgb: [30, 58, 95] },
{ key: 'bordeaux', label: 'Bordeaux', rgb: [122, 31, 47] },
{ key: 'waldgruen', label: 'Waldgrün', rgb: [27, 67, 50] },
{ key: 'stahlblau', label: 'Stahlblau', rgb: [51, 65, 85] },
{ 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: 'violett', label: 'Violett (verspielt)', rgb: [147, 51, 187] },
];
const LAYOUTS = [
{ key: 'sidebar', label: 'Klassisch mit Sidebar (Standard)' },
{ key: 'social', label: 'Social Media (verspielt)' },
{ key: 'sunny', label: 'Sunny (plakativ, Farbbänder)' },
{ key: 'tupfen', label: 'Tupfen (dezent, verspielte Punkte)' },
];
const SIDEBAR_VARIANTEN = [
{ key: 'neutral', label: 'Neutral grau (Standard)' },
{ key: 'getoent', label: 'Im Akzent getönt' },
{ key: 'weiss', label: 'Weiß (ohne Band)' },
];
const FOTO_FORMEN = [
{ key: 'eckig', label: 'Eckig' },
{ key: 'rund', label: 'Rund' },
];
const SCHRIFT_STUFEN = [
{ key: '90', label: '90 % (kompakt)' },
{ key: '95', label: '95 %' },
{ key: '100', label: '100 % (Standard)' },
{ key: '105', label: '105 %' },
{ key: '110', label: '110 % (groß)' },
];
// Defaults per layout: the playful layout is pink and round by default, because
// anthracite-and-square would defeat the point of choosing it. Any of these can
// still be overridden individually.
const LAYOUT_DEFAULTS = {
sidebar: { akzent: 'anthrazit', foto_form: 'eckig' },
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 = {
layout: 'sidebar',
akzent: 'anthrazit',
sidebar: 'neutral',
foto_anzeigen: '1',
foto_form: 'eckig',
schrift: '100',
};
const FELDER = [
{ key: 'layout', label: 'Layout', optionen: LAYOUTS },
{ key: 'akzent', label: 'Akzentfarbe', optionen: AKZENTE },
{ key: 'sidebar', label: 'Sidebar-Hintergrund', optionen: SIDEBAR_VARIANTEN, nurLayout: 'sidebar' },
{ key: 'foto_form', label: 'Form des Bewerberfotos', optionen: FOTO_FORMEN },
{ key: 'schrift', label: 'Schriftgröße', optionen: SCHRIFT_STUFEN },
];
const NEUTRAL_SIDEBAR = [242, 243, 245];
// Mix a colour towards white. amount = 0 -> white, 1 -> the colour itself.
function tint(rgb, amount) {
return rgb.map((c) => Math.round(c * amount + 255 * (1 - amount)));
}
// Mix a colour towards black.
function shade(rgb, amount) {
return rgb.map((c) => Math.round(c * amount));
}
function findOption(list, key, fallbackKey) {
return list.find((o) => o.key === key) || list.find((o) => o.key === fallbackKey);
}
// A checkbox paired with a hidden fallback submits *both* values, which Express
// hands over as an array — the last entry is the effective one.
function one(v) {
return Array.isArray(v) ? v[v.length - 1] : v;
}
// Fold the stored overrides onto the defaults, dropping unknown keys/values so a
// stale row, a hand-crafted query string or a checkbox pair can never produce an
// invalid theme. Fields not explicitly set fall back to the *layout's* default,
// which is why switching to "social" turns pink without touching anything else.
function settings(overrides = {}) {
const layoutRaw = one(overrides.layout);
const layout = LAYOUTS.some((l) => l.key === layoutRaw) ? String(layoutRaw) : DEFAULTS.layout;
const out = { ...DEFAULTS, ...(LAYOUT_DEFAULTS[layout] || {}), layout };
for (const f of FELDER) {
if (f.key === 'layout') continue;
const v = one(overrides[f.key]);
if (v && f.optionen.some((o) => o.key === String(v))) out[f.key] = String(v);
}
const foto = one(overrides.foto_anzeigen);
if (foto === '0' || foto === '1') out.foto_anzeigen = foto;
return out;
}
// Build the concrete theme the renderer draws with. Everything the PDF code
// needs to know about the user's choices is in here — the renderer itself has
// no idea these are configurable.
function resolve(overrides = {}) {
const s = settings(overrides);
const accent = findOption(AKZENTE, s.akzent, 'anthrazit').rgb;
let sidebarBg;
if (s.sidebar === 'weiss') sidebarBg = [255, 255, 255];
else if (s.sidebar === 'getoent') sidebarBg = tint(accent, 0.08);
else sidebarBg = NEUTRAL_SIDEBAR;
// Résumé palettes, one per layout. Every key exists in every palette so the
// renderer can read `t.rc.<x>` without knowing which layout it is drawing.
const RC = {
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),
sub: tint(shade(accent, 0.55), 0.75),
hair: tint(accent, 0.30),
accent,
pageBg: tint(accent, 0.10),
cardBg: [255, 255, 255],
cardBorder: tint(accent, 0.28),
// Sparkles/blob sit on the tinted page, so they need more punch than the
// card border or they simply vanish.
deko: tint(accent, 0.45),
dekoHell: tint(accent, 0.22),
band: tint(accent, 0.22),
pillBg: tint(accent, 0.18),
pillInk: shade(accent, 0.6),
sidebarBg, // unused in this layout, kept so the shape stays stable
track: tint(accent, 0.45),
onSide: shade(accent, 0.35),
onSideSub: tint(shade(accent, 0.55), 0.75),
},
// Sunny keeps the page white and puts the colour into solid blocks: pastel
// bands and rails on one side, the full-strength accent in the display
// headings and the role badge. Only two tones, used at maximum contrast.
sunny: {
ink: [40, 36, 42],
sub: [124, 118, 126],
hair: tint(accent, 0.22),
accent,
pageBg: null, // white page — the colour lives in the bands
cardBg: [255, 255, 255],
cardBorder: tint(accent, 0.22),
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),
pillInk: shade(accent, 0.72),
sidebarBg, // unused
track: tint(accent, 0.20), // an unfilled dot of a language meter
onSide: [26, 26, 28],
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
// set — a playful CV never arrives with a stiff monochrome letter.
const LC = {
sidebar: {
ink: [26, 26, 26],
muted: [92, 96, 100],
hair: [206, 208, 212],
accent,
pageBg: null,
cardBg: [255, 255, 255],
cardBorder: [206, 208, 212],
deko: [206, 208, 212],
dekoHell: [230, 232, 235],
band: rc.band,
pillBg: tint(accent, 0.12),
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 {
layout: s.layout,
fonts: FONTS[s.layout] || FONTS.sidebar,
rc,
lc,
foto: {
anzeigen: s.foto_anzeigen !== '0',
rund: s.foto_form === 'rund',
},
// Starting scale for the one-page fit. The auto-scaler may still shrink
// below this when there is a lot of content — it never grows past it.
scale: (Number(s.schrift) || 100) / 100,
};
}
// Field list for the Vorlagen UI, with the current selection marked. Fields that
// only apply to one layout carry `nurLayout` so the form can hide them.
function list(overrides = {}) {
const s = settings(overrides);
return FELDER.map((f) => ({
key: f.key,
label: f.label,
aktuell: s[f.key],
nurLayout: f.nurLayout || null,
optionen: f.optionen.map((o) => ({ key: o.key, label: o.label, gewaehlt: o.key === s[f.key] })),
}));
}
// True when the user deviates from what the chosen layout would ship with.
function isAngepasst(overrides = {}) {
const s = settings(overrides);
const basis = { ...DEFAULTS, ...(LAYOUT_DEFAULTS[s.layout] || {}), layout: s.layout };
return s.layout !== DEFAULTS.layout || Object.keys(basis).some((k) => s[k] !== basis[k]);
}
module.exports = {
DEFAULTS, LAYOUT_DEFAULTS, AKZENTE, LAYOUTS, FELDER,
settings, resolve, list, isAngepasst,
defaultTheme: () => resolve({}),
};
+2372 -238
View File
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Pacifico Project Authors (https://github.com/googlefonts/Pacifico)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+44
View File
@@ -0,0 +1,44 @@
// Shared label vocabulary for Stellen (Bewerbungen + Jobangebote).
//
// Labels describe the work-location character of a position. Several labels may
// be set per Stelle. They are stored as a JSON array string in the `labels`
// column and exposed as a string[] through the web UI and the REST API.
const LABEL_OPTIONS = [
'Regional',
'Remote-Deutschlandweit',
'Homeoffice-Deutschlandweit',
];
// Normalize any incoming labels value (array, JSON string, comma-separated
// string, or a single label) into a clean, de-duplicated array limited to
// LABEL_OPTIONS and returned in canonical order for stable output.
function parseLabels(raw) {
if (raw == null || raw === '') return [];
let arr;
if (Array.isArray(raw)) {
arr = raw;
} else if (typeof raw === 'string') {
const s = raw.trim();
if (!s) return [];
if (s[0] === '[') {
try { arr = JSON.parse(s); } catch (e) { arr = []; }
if (!Array.isArray(arr)) arr = [];
} else {
arr = s.split(',');
}
} else {
return [];
}
const chosen = new Set(
arr.map((v) => String(v).trim()).filter((v) => LABEL_OPTIONS.includes(v))
);
return LABEL_OPTIONS.filter((l) => chosen.has(l));
}
// Canonical JSON string for storage in the DB (always a valid array).
function serializeLabels(raw) {
return JSON.stringify(parseLabels(raw));
}
module.exports = { LABEL_OPTIONS, parseLabels, serializeLabels };
+26 -12
View File
@@ -11,18 +11,21 @@
const nodemailer = require('nodemailer');
const { ImapFlow } = require('imapflow');
const { simpleParser } = require('mailparser');
const config = require('./config');
// Read fresh on every call so an edit on the /einstellungen page takes effect
// immediately (values live in the DB now, not in process.env/.env).
function cfg() {
const smtpPort = Number(process.env.MAIL_SMTP_PORT || 587);
const smtpPort = Number(config.get('MAIL_SMTP_PORT') || 587);
return {
host: process.env.MAIL_HOST || '',
host: config.get('MAIL_HOST') || '',
smtpPort,
imapPort: Number(process.env.MAIL_IMAP_PORT || 993),
user: process.env.MAIL_USER || '',
pass: process.env.MAIL_PASSWORD || '',
fromName: process.env.MAIL_FROM_NAME || '',
fromAddr: process.env.MAIL_FROM || process.env.MAIL_USER || '',
mailbox: process.env.MAIL_IMAP_MAILBOX || 'INBOX',
imapPort: Number(config.get('MAIL_IMAP_PORT') || 993),
user: config.get('MAIL_USER') || '',
pass: config.get('MAIL_PASSWORD') || '',
fromName: config.get('MAIL_FROM_NAME') || '',
fromAddr: config.get('MAIL_FROM') || config.get('MAIL_USER') || '',
mailbox: config.get('MAIL_IMAP_MAILBOX') || 'INBOX',
// secure=true means implicit TLS (465); 587 uses STARTTLS (requireTLS).
smtpSecure: smtpPort === 465,
};
@@ -62,12 +65,22 @@ async function verify() {
}
// Minimal HTML rendering of a plain-text body: escape, then turn newlines into
// <br>. Sending multipart/alternative (text + html) reads as normal personal
// mail and avoids the "text-only, no html" heuristic some filters apply.
// explicit <br> tags. Sending multipart/alternative (text + html) reads as
// normal personal mail and avoids the "text-only, no html" heuristic some
// filters apply.
//
// The line breaks MUST be real <br> tags, not merely CSS white-space:pre-wrap
// on literal newlines: pre-wrap renders correctly on first receipt, but when
// the recipient's client (e.g. Outlook) quotes the message back to us it
// re-parses the HTML, drops the pre-wrap style and collapses the bare newlines
// into spaces — turning our carefully formatted reply into a run-on wall of
// text in the quoted thread. Explicit <br> survives that round-trip.
function textToHtml(text) {
const esc = String(text || '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a;white-space:pre-wrap">${esc}</div>`;
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\r\n?/g, '\n')
.replace(/\n/g, '<br>\n');
return `<div style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;color:#1a1a1a">${esc}</div>`;
}
// Send one message via authenticated submission. Returns nodemailer info
@@ -175,5 +188,6 @@ module.exports = {
fromField,
fromAddress,
textToHtml,
htmlToText,
config: cfg,
};
+339
View File
@@ -0,0 +1,339 @@
// Idempotent migration from the single-user schema to the multi-user schema.
//
// Used both at server boot (server.js calls runMigration after opening the DB)
// and by the standalone scripts/migrate-to-multiuser.js. Safe to run repeatedly:
// every step guards itself with "already done?" checks.
//
// What it does, in order:
// 1. Create the `users` + `sessions` tables.
// 2. Ensure an `admin` user exists (password "admin", scrypt hash). Record its id.
// 3. Add a `user_id` column to every per-user table (ALTER ADD COLUMN, nullable
// for upgraded installs — fresh installs create it NOT NULL directly) and
// backfill every existing row to the admin id.
// 4. Recreate the tables whose PRIMARY KEY / UNIQUE must include user_id so the
// constraint becomes per-user: app_state, settings, prompts, design,
// jobangebote. Old rows are copied to the admin user.
// 5. Move on-disk attachment files into a per-user subdirectory for the admin.
// 6. One-time migration of any still-present .env values into the admin's cfg.
//
// After this, the app boots against a fully multi-user schema and all pre-existing
// data is owned by the admin user.
const fs = require('fs');
const path = require('path');
const password = require('./password');
const config = require('./config');
// Tables that need a `user_id` column added (legacy upgrades). Fresh installs
// create these with user_id NOT NULL directly in initializeDatabase().
const USER_TABLES = [
'bewerbungen', 'status_verlauf', 'anhaenge', 'interne_anhaenge',
'emails', 'email_anhaenge', 'basis_dokumente', 'basis_anhaenge',
'termine', 'chat_threads', 'chat_messages',
'jobangebote_blacklist',
];
// jobangebote is NOT in USER_TABLES: its user_id column + per-user UNIQUE
// constraint are installed by the recreate in step 4 (which needs user_id to be
// absent so the rebuild fires). Adding it here first would make step 4's
// !hasColumn guard short-circuit and leave the legacy single-user UNIQUE in
// place, breaking per-user isolation on ingest.
const ADMIN_USERNAME = 'admin';
const ADMIN_DEFAULT_PASSWORD = 'admin';
async function runMigration({ db, dbAll, dbGet, dbRun }) {
// Wire the config store to the same DB so config.setForUser works during the
// one-time .env migration below.
await config.init({ dbAll, dbRun });
const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve()));
const tableExists = async (name) => {
const row = await dbGet("SELECT name FROM sqlite_master WHERE type='table' AND name=?", [name]);
return !!row;
};
const columnsOf = async (table) => {
const rows = await dbAll(`PRAGMA table_info(${table})`);
return rows.map((r) => r.name);
};
const hasColumn = async (table, col) => (await columnsOf(table)).includes(col);
// 1. users + sessions -------------------------------------------------
await exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await exec(`
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
await exec('CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)');
// Impersonation support: the admin's session keeps its token but switches
// user_id to the target, storing the original admin in impersonator_id so the
// admin can switch back. Added after the fact for existing installs.
if (!(await hasColumn('sessions', 'impersonator_id'))) {
await exec('ALTER TABLE sessions ADD COLUMN impersonator_id INTEGER');
}
// "Angemeldet bleiben": vorher galten alle Sessions 30 Tage. Bestehende
// Sessions behalten dieses Verhalten (Default 1) — niemand wird durch das
// Update ausgeloggt.
if (!(await hasColumn('sessions', 'dauerhaft'))) {
await exec('ALTER TABLE sessions ADD COLUMN dauerhaft INTEGER NOT NULL DEFAULT 1');
}
// 2. Ensure admin user (idempotent) -----------------------------------
let admin = await dbGet('SELECT id, password_hash FROM users WHERE username = ?', [ADMIN_USERNAME]);
if (!admin) {
const hash = password.hash(ADMIN_DEFAULT_PASSWORD);
const res = await dbRun(
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)',
[ADMIN_USERNAME, hash]
);
admin = { id: res.lastID };
console.log(`Multi-User-Migration: Admin-Benutzer „${ADMIN_USERNAME}“ angelegt (Passwort „${ADMIN_DEFAULT_PASSWORD}“). Bitte nach erstem Login ändern.`);
}
const adminId = admin.id;
// 3. Add user_id column + backfill ------------------------------------
for (const tbl of USER_TABLES) {
if (!(await tableExists(tbl))) continue; // table may not exist yet on a partial install
if (!(await hasColumn(tbl, 'user_id'))) {
await exec(`ALTER TABLE ${tbl} ADD COLUMN user_id INTEGER`);
}
// Backfill every row that is not yet assigned (NULL) to the admin.
await dbRun(`UPDATE ${tbl} SET user_id = ? WHERE user_id IS NULL`, [adminId]);
}
// 4. Recreate tables whose PK/UNIQUE must be per-user -----------------
// app_state: PK(key) -> PK(user_id, key)
if (await tableExists('app_state')) {
if (!(await hasColumn('app_state', 'user_id'))) {
await recreate(db, dbAll, dbRun, 'app_state',
`CREATE TABLE app_state (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT,
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`INSERT INTO app_state (user_id, key, value) SELECT ?, key, value FROM app_state_old`,
[adminId]
);
} else {
await dbRun('UPDATE app_state SET user_id = ? WHERE user_id IS NULL', [adminId]);
}
}
// prompts: PK(key) -> PK(user_id, key)
if (await tableExists('prompts') && !(await hasColumn('prompts', 'user_id'))) {
await recreate(db, dbAll, dbRun, 'prompts',
`CREATE TABLE prompts (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
inhalt TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`INSERT INTO prompts (user_id, key, inhalt, updated_at) SELECT ?, key, inhalt, updated_at FROM prompts_old`,
[adminId]
);
} else if (await tableExists('prompts')) {
await dbRun('UPDATE prompts SET user_id = ? WHERE user_id IS NULL', [adminId]);
}
// design: PK(key) -> PK(user_id, key)
if (await tableExists('design') && !(await hasColumn('design', 'user_id'))) {
await recreate(db, dbAll, dbRun, 'design',
`CREATE TABLE design (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`INSERT INTO design (user_id, key, value, updated_at) SELECT ?, key, value, updated_at FROM design_old`,
[adminId]
);
} else if (await tableExists('design')) {
await dbRun('UPDATE design SET user_id = ? WHERE user_id IS NULL', [adminId]);
}
// settings: single row CHECK(id=1) -> per-user row (user_id UNIQUE)
if (await tableExists('settings') && !(await hasColumn('settings', 'user_id'))) {
await recreate(db, dbAll, dbRun, 'settings',
`CREATE TABLE settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
name TEXT,
adresse TEXT,
kundennummer TEXT,
ort TEXT,
webseite TEXT,
email TEXT,
telefon TEXT,
geburtsdatum TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum)
SELECT ?, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum FROM settings_old`,
[adminId]
);
} else if (await tableExists('settings')) {
await dbRun('UPDATE settings SET user_id = ? WHERE user_id IS NULL', [adminId]);
}
// jobangebote: UNIQUE(quelle, external_id) -> UNIQUE(user_id, quelle, external_id)
if (await tableExists('jobangebote') && !(await hasColumn('jobangebote', 'user_id'))) {
// user_id was added in step 3 already; this branch only recreates for the
// per-user UNIQUE constraint. Rebuild preserving all columns.
const cols = (await columnsOf('jobangebote')).filter((c) => c !== 'user_id');
const colList = cols.join(', ');
await recreate(db, dbAll, dbRun, 'jobangebote',
`CREATE TABLE jobangebote (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
external_id TEXT,
quelle TEXT NOT NULL DEFAULT 'drittanbieter',
firma TEXT NOT NULL,
stelle TEXT NOT NULL,
ort TEXT,
adresse TEXT,
ansprechpartner TEXT,
gehalt TEXT,
beschreibung TEXT,
quelle_url TEXT,
art TEXT,
anzeige_datum DATE,
kontakt_email TEXT,
status TEXT NOT NULL DEFAULT 'offen',
verknuepfte_bewerbung_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
url_norm TEXT,
firma_slug TEXT,
labels TEXT,
UNIQUE (user_id, quelle, external_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
)`,
`INSERT INTO jobangebote (user_id, ${colList}) SELECT ?, ${colList} FROM jobangebote_old`,
[adminId]
);
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)');
}
// Ensure a settings row exists for the admin (idempotent). On a fresh install
// the settings table does not exist yet at this point (it is created by the
// server's CREATE TABLE IF NOT EXISTS right after the migration), so skip —
// the row is upserted on the admin's first /vorlagen save anyway.
if (await tableExists('settings')) {
const srow = await dbGet('SELECT id FROM settings WHERE user_id = ?', [adminId]);
if (!srow) {
await dbRun(
`INSERT INTO settings (user_id, name, adresse, kundennummer, ort, webseite, email, telefon, geburtsdatum)
VALUES (?, 'Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', '', '', '', '', '', '')`,
[adminId]
);
}
}
// 5. Move on-disk files into a per-user subdirectory for the admin -----
moveFilesIntoUserSubdir(adminId);
// 6. One-time env -> admin cfg import ----------------------------------
// On an upgraded install app_state already exists here, so this fills the
// admin's rows right away. On a *fresh* install the table is only created
// after this migration returns, so this is a no-op — server.js therefore
// calls importEnvIntoAdmin() again once the schema is complete. Idempotent
// either way (it only fills keys the admin has not stored).
await migrateEnvForAdmin(dbAll, adminId);
return { adminId };
}
// Import any still-present env config into the admin's rows. Called at boot from
// server.js, after initializeDatabase() has created app_state — this is what
// makes a deployment that passes its config as container env vars (as ours does)
// end up with those values owned by the admin, instead of leaking to every user
// through a read-time process.env fallback (which config.get() no longer has).
async function importEnvIntoAdmin({ dbAll, dbGet }) {
const admin = await dbGet('SELECT id FROM users WHERE username = ?', [ADMIN_USERNAME]);
if (!admin) return;
await migrateEnvForAdmin(dbAll, admin.id);
}
// Rename `table` to `table_old`, create the new table from `newSchemaSql`,
// copy rows via `copySql` (with `copyParams`), then drop `table_old`.
async function recreate(db, dbAll, dbRun, table, newSchemaSql, copySql, copyParams) {
const exec = (sql) => new Promise((resolve, reject) => db.run(sql, (err) => err ? reject(err) : resolve()));
await exec(`ALTER TABLE ${table} RENAME TO ${table}_old`);
await exec(newSchemaSql);
await dbRun(copySql, copyParams || []);
await exec(`DROP TABLE ${table}_old`);
}
// Move every file in each per-user storage directory into a `<userId>/` subdir.
// Idempotent: if the subdir already contains files (already migrated), leave the
// top-level files alone (they would be a re-run left-overs) — but on first run the
// top level holds the legacy flat files, which we move in.
const STORAGE_DIRS = ['anhaenge', 'basis_anhaenge', 'interne_anhaenge', 'email_anhaenge', 'signatur', 'bewerberfoto'];
function moveFilesIntoUserSubdir(userId) {
const dataDir = path.join(__dirname, '..', 'data');
for (const dir of STORAGE_DIRS) {
const base = path.join(dataDir, dir);
if (!fs.existsSync(base)) continue;
const userDir = path.join(base, String(userId));
if (!fs.existsSync(userDir)) fs.mkdirSync(userDir, { recursive: true });
for (const name of fs.readdirSync(base)) {
if (name === String(userId)) continue;
const src = path.join(base, name);
if (!fs.statSync(src).isFile()) continue; // skip subdirectories
const dst = path.join(userDir, name);
if (!fs.existsSync(dst)) fs.renameSync(src, dst);
}
}
}
// Move any still-present .env value for a DEFAULT key that the admin hasn't got
// stored yet into the admin's cfg rows. Idempotent: only fills keys that are not
// yet present for the admin.
async function migrateEnvForAdmin(dbAll, adminId) {
// On a fresh install app_state does not exist yet at this point (created by
// the server right after the migration), so there is nothing to read from or
// write to — skip. The .env values, if any, then fall back via config.get().
try {
await dbAll('SELECT 1 FROM app_state LIMIT 1');
} catch (e) {
return;
}
const storedRows = await dbAll('SELECT key FROM app_state WHERE user_id = ?', [adminId]);
const stored = new Set(storedRows.map((r) => r.key.slice(config.PREFIX.length)));
const migrated = [];
for (const key of Object.keys(config.DEFAULTS)) {
if (stored.has(key)) continue; // already in the DB, never overwrite from .env
const envVal = process.env[key];
if (envVal && envVal.length) {
await config.setForUser(adminId, key, envVal);
migrated.push(key);
}
}
if (migrated.length) {
console.log(`Multi-User-Migration: ${migrated.length} Konfigurationswerte aus .env in den Admin-Benutzer migriert — .env wird nicht mehr benötigt.`);
}
}
module.exports = { runMigration, importEnvIntoAdmin, moveFilesIntoUserSubdir, STORAGE_DIRS, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD };
+102 -15
View File
@@ -8,9 +8,19 @@ const ART_OPTIONS = [
'Arbeitsagentur', 'Sonstiges',
];
const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Absage', 'Einstellung', 'Keine Rückmeldung',
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Vertragsverhandlung',
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
];
const { LABEL_OPTIONS } = require('./labels');
// Reusable schema for the labels array (work-location labels of a Stelle).
const labelsSchema = {
type: 'array',
items: { type: 'string', enum: LABEL_OPTIONS },
description: 'Labels der Stelle (Arbeitsort). Mehrere möglich.',
};
// Shared reusable schemas -----------------------------------------------
@@ -27,15 +37,26 @@ function buildOpenApiSpec(baseUrl = '') {
return {
openapi: '3.0.3',
info: {
title: 'Bewerbungs-Tracker REST-API',
title: 'NextJobs REST-API',
version: '1.0.0',
description:
'REST-API für Drittanbietersoftware zum Lesen und Verwalten von ' +
'Bewerbungen, Statusverlauf, generierten Unterlagen (PDFs), ' +
'E-Mail-Korrespondenz, Einstellungen und Statistiken.\n\n' +
'Alle Endpunkte sind unter `/api/v1` gemountet und erfordern ' +
'Authentifizierung über einen API-Key im Header `X-API-Key` ' +
'(konfiguriert via Umgebungsvariable `API_TOKEN`).',
'Authentifizierung über einen API-Key im Header `X-API-Key`.\n\n' +
'### Der API-Key gehört immer genau einem Benutzer\n\n' +
'Der Tracker ist eine Multi-User-Plattform. Jeder Benutzer legt seinen ' +
'eigenen Token unter **Einstellungen → REST-API** an („Neu generieren“) — ' +
'es gibt keinen globalen oder serverweiten Key.\n\n' +
'Der übergebene `X-API-Key` bestimmt, **wessen** Daten die Anfrage sieht: ' +
'jeder Endpunkt liest und schreibt ausschließlich die Daten des Benutzers, ' +
'dem der Token gehört. Fremde Bewerbungen, E-Mails, Anhänge, Jobangebote ' +
'oder Einstellungen sind über die API nicht erreichbar — auch nicht durch ' +
'Angabe einer fremden `id`. In dem Fall antwortet die API mit `404`, als ' +
'gäbe es den Datensatz nicht. Ein unbekannter, leerer oder mehrdeutiger ' +
'Token führt zu `401`.\n\n' +
'Ein neu generierter Token macht den vorherigen sofort ungültig.',
},
servers: baseUrl ? [{ url: `${baseUrl}/api/v1` }] : [{ url: '/api/v1' }],
tags: [
@@ -79,6 +100,7 @@ function buildOpenApiSpec(baseUrl = '') {
{ name: 'year', in: 'query', schema: { type: 'string', pattern: '^[0-9]{4}$' }, description: 'Jahr (z. B. 2026)' },
{ name: 'status', in: 'query', schema: { type: 'string', enum: STATUS_OPTIONS }, description: 'Filter nach Status' },
{ name: 'art', in: 'query', schema: { type: 'string', enum: ART_OPTIONS }, description: 'Filter nach Bewerbungsart' },
{ name: 'label', in: 'query', schema: { type: 'string', enum: LABEL_OPTIONS }, description: 'Filter nach Label (Stellen mit diesem Label)' },
{ name: 'search', in: 'query', schema: { type: 'string' }, description: 'Freitextsuche in Firma und Stelle' },
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 500, default: 100 }, description: 'Max. Anzahl Ergebnisse' },
{ name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, default: 0 }, description: 'Ergebnis-Offset (Paging)' },
@@ -351,7 +373,17 @@ function buildOpenApiSpec(baseUrl = '') {
items: { type: 'integer' },
description:
'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: {
type: 'array',
items: { type: 'string', enum: ['anschreiben', 'lebenslauf'] },
description:
'Welche Unterlagen erzeugt werden sollen. Weglassen = Vorauswahl aus den Einstellungen ' +
'(GEN_DOKUMENTE_DEFAULT, Standard: beide). Wird der Lebenslauf nicht erzeugt, führt ihn ' +
'das Anschreiben auch nicht unter "Anlagen" auf, und der E-Mail-Begleittext kündigt ihn nicht an.',
example: ['anschreiben'],
},
},
},
@@ -390,22 +422,48 @@ function buildOpenApiSpec(baseUrl = '') {
'/settings': {
get: {
tags: ['Settings'],
summary: 'Einstellungen abrufen',
summary: 'Persönliche Angaben abrufen',
description:
'Liefert die persönlichen Angaben **des Benutzers, dem der API-Key gehört**. ' +
'Hat er noch nichts gespeichert, ist die Antwort ein leeres Objekt `{}`.',
responses: {
'200': { description: 'Einstellungen', content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } } },
'200': { description: 'Persönliche Angaben (ggf. `{}`)', content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } } },
'401': errorResponse,
'500': errorResponse,
},
},
put: {
tags: ['Settings'],
summary: 'Einstellungen speichern',
summary: 'Persönliche Angaben speichern',
description:
'Speichert die persönlichen Angaben des eigenen Benutzers. Es werden nur die ' +
'Felder geändert, die im Body vorkommen — weggelassene Felder behalten ihren ' +
'Wert. Existiert noch keine Zeile, wird sie angelegt.',
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/Settings' } } },
content: {
'application/json': {
schema: { $ref: '#/components/schemas/Settings' },
example: { name: 'Max Mustermann', ort: 'Berlin' },
},
},
},
responses: {
'200': { description: 'Gespeichert', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
'200': {
description: 'Gespeichert; gibt den neuen Stand zurück',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
settings: { $ref: '#/components/schemas/Settings' },
},
},
},
},
},
'400': errorResponse,
'401': errorResponse,
'500': errorResponse,
},
@@ -635,7 +693,11 @@ function buildOpenApiSpec(baseUrl = '') {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
description: 'API-Token aus der Umgebungsvariable API_TOKEN.',
description:
'Persönlicher API-Token des Benutzers, zu finden unter Einstellungen → ' +
'REST-API (dort per „Neu generieren“ erzeugen und einblenden). Der Token ' +
'identifiziert den Benutzer — die Anfrage sieht und ändert nur dessen ' +
'eigene Daten.',
},
},
schemas: {
@@ -663,6 +725,7 @@ function buildOpenApiSpec(baseUrl = '') {
stelle: { type: 'string' },
art: { type: 'string', enum: ART_OPTIONS, nullable: true },
status: { type: 'string', enum: STATUS_OPTIONS, nullable: true },
labels: { ...labelsSchema, nullable: true },
notizen: { type: 'string', nullable: true },
interne_notizen: { type: 'string', nullable: true },
ort: { type: 'string', nullable: true },
@@ -692,6 +755,7 @@ function buildOpenApiSpec(baseUrl = '') {
stelle: { type: 'string' },
art: { type: 'string', enum: ART_OPTIONS },
status: { type: 'string', enum: STATUS_OPTIONS },
labels: labelsSchema,
notizen: { type: 'string' },
interne_notizen: { type: 'string' },
ort: { type: 'string' },
@@ -711,6 +775,7 @@ function buildOpenApiSpec(baseUrl = '') {
stelle: { type: 'string' },
art: { type: 'string', enum: ART_OPTIONS },
status: { type: 'string', enum: STATUS_OPTIONS },
labels: labelsSchema,
notizen: { type: 'string' },
interne_notizen: { type: 'string' },
ort: { type: 'string' },
@@ -817,10 +882,19 @@ function buildOpenApiSpec(baseUrl = '') {
},
Settings: {
type: 'object',
description:
'Persönliche Angaben des Benutzers, dem der API-Key gehört (fließen in ' +
'die generierten Unterlagen ein). Hat der Benutzer noch nichts gespeichert, ' +
'liefert GET ein leeres Objekt `{}` — das ist ein gültiger Zustand.',
properties: {
name: { type: 'string' },
adresse: { type: 'string' },
kundennummer: { type: 'string' },
name: { type: 'string', example: 'Max Mustermann' },
adresse: { type: 'string', example: 'Musterstraße 1, 12345 Musterstadt' },
kundennummer: { type: 'string', description: 'Kundennummer beim Jobcenter' },
email: { type: 'string', example: 'max@example.com' },
telefon: { type: 'string', example: '030 12345678' },
ort: { type: 'string', example: 'Berlin' },
webseite: { type: 'string', example: 'example.com' },
geburtsdatum: { type: 'string', example: '01.01.1990' },
},
},
Statistics: {
@@ -871,8 +945,18 @@ function buildOpenApiSpec(baseUrl = '') {
anzeige_datum: { type: 'string', format: 'date', nullable: true, description: 'Datum der eigentlichen Stellenanzeige (vom Drittanbieter übergeben)' },
kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' },
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] },
favorit: {
type: 'integer',
enum: [0, 1],
readOnly: true,
description: 'Vom Nutzer in der Weboberfläche gemerktes Angebot (Stern). Wird nur dort gesetzt ' +
'und beim erneuten Einspielen desselben Angebots nicht überschrieben. Unabhängig vom ' +
'Favoriten-Merkmal einer Bewerbung.',
},
labels: { ...labelsSchema, nullable: true },
verknuepfte_bewerbung_id: { type: 'integer', nullable: true },
url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' },
firma_slug: { type: 'string', nullable: true, description: 'Firmen-Slug für Blacklist-Abgleich; wie übergeben gespeichert, bei Fehlen aus firma abgeleitet.' },
created_at: { type: 'string', format: 'date-time' },
updated_at: { type: 'string', format: 'date-time' },
},
@@ -891,10 +975,12 @@ function buildOpenApiSpec(baseUrl = '') {
gehalt: { type: 'string' },
beschreibung: { type: 'string', description: 'Stellenbeschreibungstext' },
quelle_url: { type: 'string' },
firma_slug: { type: 'string', description: 'Optionaler Firmen-Slug für den Blacklist-Abgleich (z. B. "bosch"). Wird wie übergeben gespeichert; fehlt er, leitet der Server ihn aus firma ab.' },
art: { type: 'string', enum: ART_OPTIONS },
anzeige_datum: { type: 'string', format: 'date', description: 'Datum der eigentlichen Stellenanzeige (ISO YYYY-MM-DD)' },
kontakt_email: { type: 'string', format: 'email', description: 'Kontakt-E-Mail-Adresse der Stelle' },
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'], default: 'offen' },
labels: labelsSchema,
},
},
JobOfferResult: {
@@ -923,6 +1009,7 @@ function buildOpenApiSpec(baseUrl = '') {
quelle: { type: 'string', nullable: true },
external_id: { type: 'string', nullable: true },
firma_norm: { type: 'string', nullable: true },
firma_slug: { type: 'string', nullable: true, description: 'Rechtsform-/Umlaut-robuster Firmen-Slug; primärer Abgleich für typ=firma/firma_stelle/auto.' },
stelle_norm: { type: 'string', nullable: true },
ort_norm: { type: 'string', nullable: true },
firma: { type: 'string', nullable: true },
+43
View File
@@ -0,0 +1,43 @@
// Password hashing using Node's built-in scrypt + a per-hash random salt.
//
// No external dependency (bcrypt would need a native build step). scrypt is
// memory-hard and well suited for interactive logins. Hash format:
// "<saltHex>:<hashHex>" (salt is 16 bytes, hash is 64 bytes)
//
// verify() is async (crypto.scrypt, non-blocking) so a login attempt cannot pin
// the event loop and stall every other request — a synchronous scrypt was a
// cheap CPU-DoS vector. DUMMY_HASH is a real hash for the "unknown user" path so
// login timing does not leak which usernames exist.
const crypto = require('crypto');
const KEYLEN = 64;
const DUMMY_SALT = '00000000000000000000000000000000';
const DUMMY_HASH = `${DUMMY_SALT}:${crypto.scryptSync('nextjobs-dummy', DUMMY_SALT, KEYLEN).toString('hex')}`;
function hash(password) {
const salt = crypto.randomBytes(16).toString('hex');
const out = crypto.scryptSync(password, salt, KEYLEN).toString('hex');
return `${salt}:${out}`;
}
// Resolve to true/false. Always runs a full scrypt (constant work) before
// deciding, so the caller cannot tell a wrong password from a bad hash format.
function verify(password, stored) {
return new Promise((resolve) => {
if (typeof stored !== 'string' || !stored.includes(':')) return resolve(false);
const idx = stored.indexOf(':');
const salt = stored.slice(0, idx);
const expected = stored.slice(idx + 1);
if (!salt || !expected) return resolve(false);
crypto.scrypt(String(password == null ? '' : password), salt, KEYLEN, (err, buf) => {
if (err) return resolve(false);
const computed = buf.toString('hex');
if (computed.length !== expected.length) return resolve(false);
// Constant-time compare to avoid timing side channels.
resolve(crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected)));
});
});
}
module.exports = { hash, verify, DUMMY_HASH };
+159
View File
@@ -0,0 +1,159 @@
// Editable KI system prompts.
//
// Every prompt that shapes the assistant's role, tone and rules lives here as a
// default and can be overridden per installation on the Vorlagen page. Only the
// *system* prompts are editable: the user prompts carry the JSON skeleton the
// answers are parsed against, so they stay in code where a typo can't break
// generation. An override is a row in the `prompts` table; deleting the row
// restores the default (that's what "Zurücksetzen" does).
//
// Placeholders are written as {{name}} and filled by render().
const DEFAULTS = [
{
key: 'unterlagen',
titel: 'Bewerbungsunterlagen (Anschreiben & Lebenslauf)',
beschreibung:
'Rolle und Regeln für die Generierung der Bewerbungsunterlagen aus deinen Basis-Unterlagen. ' +
'Der Aufbau der Dokumente (JSON-Struktur, Felder) ist fest verdrahtet und wird hiervon nicht berührt.',
platzhalter: [],
inhalt:
'Du bist ein erfahrener Bewerbungscoach und erstellst professionelle, ' +
'deutschsprachige Bewerbungstexte. Du passt die BEREITGESTELLTEN Basis-Unterlagen ' +
'des Bewerbers auf eine konkrete Stellenausschreibung an. Wichtigste Regel: Du ' +
'erfindest KEINE Fakten, Qualifikationen, Abschlüsse, Kontaktdaten oder ' +
'Berufserfahrungen. Verwende ausschließlich Informationen aus den Basis-Unterlagen. ' +
'Du darfst umformulieren, gewichten, relevante Punkte hervorheben und auf die Stelle ' +
'zuschneiden — aber nichts hinzudichten. Schreibe natürlich, konkret und ohne Floskeln, so wie ein ' +
'deutscher Muttersprachler schreibt: keine Buzzword-Paare, keine leeren Eigenschaftswörter und keine ' +
'Selbstetiketten wie "Als Muttersprachler ..."; jede Aussage aus einer konkreten Tätigkeit heraus. ' +
'Verwende AUSSCHLIESSLICH deutsche Sprache und das lateinische Alphabet. Keine englischen, ' +
'chinesischen, japanischen, koreanischen oder anderen fremdsprachigen Wörter oder Schriftzeichen - ' +
'auch nicht einzelne Zeichen oder Füllwörter.',
},
{
key: 'chat',
titel: 'KI-Chat (Bewerbungs-Assistent)',
beschreibung:
'Grundhaltung des Chat-Assistenten. Die Hinweise zu Werkzeugen, Datum und deinem Profil ' +
'werden automatisch ergänzt.',
platzhalter: ['name'],
inhalt:
'Du bist ein hilfreicher, deutschsprachiger Bewerbungs-Assistent für {{name}}. ' +
'Du beantwortest Fragen zu laufenden Bewerbungen, hilfst beim Formulieren von ' +
'Antworten, beim Vorbereiten auf Gespräche und beim Überblick über den Status. ' +
'Antworte natürlich, knapp und auf Deutsch im lateinischen Alphabet. ' +
'Erfinde KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen, Firmen). ' +
'Wenn eine Angabe nötig ist, die du nicht weißt, setze einen klar erkennbaren ' +
'Platzhalter in eckigen Klammern. Verwende nur den einfachen Bindestrich "-". ' +
'Fasse Stellenbeschreibungen verständlich zusammen, statt sie wörtlich ' +
'wiederzugeben, und beziehe sie auf das Profil des Bewerbers.',
},
{
key: 'email_antwort',
titel: 'E-Mail: Antwort auf eine Nachricht',
beschreibung: 'Rolle für den KI-Entwurf einer Antwort-E-Mail an ein Unternehmen.',
platzhalter: ['name'],
inhalt:
'Du bist {{name}} und schreibst als Bewerber eine höfliche, professionelle, ' +
'deutschsprachige Antwort-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' +
'Du antwortest konkret auf die eingegangene Nachricht.',
},
{
key: 'email_absage',
titel: 'E-Mail: Absage (andere Stelle angenommen)',
beschreibung: 'Rolle für den KI-Entwurf einer Absage an ein Unternehmen.',
platzhalter: ['name'],
inhalt:
'Du bist {{name}} und schreibst als Bewerber eine höfliche, professionelle, ' +
'deutschsprachige Absage-E-Mail an ein Unternehmen im laufenden Bewerbungsprozess. ' +
'Du teilst mit, dass du dich entschieden hast, eine andere Stelle anzunehmen, und ' +
'bedankst dich für die Zeit, das Gespräch und das Vertrauen. Halte die E-Mail bewusst ' +
'kurz: ein bis zwei kurze Absätze genügen - keine langen Erklärungen, keine Rechtfertigung. ' +
'Bleibe wertschätzend und bestimmt zugleich; nenne keine Gründe im Detail und erfinde keine ' +
'konkreten Namen anderer Unternehmen.',
},
{
key: 'email_stil',
titel: 'E-Mail: gemeinsame Stilregeln',
beschreibung:
'Wird an beide E-Mail-Prompts oben angehängt. Hier stehen die Regeln, die für jede ' +
'generierte E-Mail gelten sollen.',
platzhalter: [],
inhalt:
'Wichtig: Du erfindest KEINE Fakten (keine erfundenen Termine, Zahlen, Zusagen). ' +
'Wenn eine konkrete Angabe nötig ist, die du nicht kennst, setze einen klar erkennbaren ' +
'Platzhalter in eckigen Klammern, z. B. "[Terminvorschlag einfügen]". Schreibe natürlich, ' +
'knapp und verbindlich, wie ein deutscher Muttersprachler - ohne leere Floskeln, ohne ' +
'Buzzword-Paare und ohne Selbstetiketten wie "Als Muttersprachler ...". Verwende ausschließlich ' +
'deutsche Sprache und das lateinische Alphabet - keine fremdsprachigen Wörter oder Schriftzeichen.',
},
{
key: 'jobsuche_feinschliff',
titel: 'Jobsuche: Feinschliff vorschlagen',
beschreibung:
'Rolle für den KI-Vorschlag deiner Jobsuche (Berufsbezeichnungen / zusätzlich berücksichtigen / ' +
'ausschließen). Grundlage sind deine Basis-Unterlagen oben.',
platzhalter: [],
inhalt:
'Du bist ein erfahrener Recruiter und stellst die Suchkriterien für die automatische ' +
'Stellensuche eines Bewerbers auf. Du liest seine Basis-Unterlagen (Lebenslauf, Kurzprofil, ' +
'Anschreiben). Das Wichtigste sind die Berufsbezeichnungen: unter welchen Stellentiteln seine ' +
'Tätigkeit real ausgeschrieben wird - denn danach wird gesucht, und was hier fehlt, findet die ' +
'Suche nie. Dieselbe Arbeit läuft je nach Firma unter verschiedenen Titeln; decke diese ' +
'Varianten ab, aber bleib bei dem, was sein Erfahrungsniveau hergibt. Zusatzbegriffe sind ' +
'KEINE Stellentitel, sondern belegbarer Kontext (Technologien, Branchen, Umfelder, ' +
'Arbeitszeitmodelle). Ausschlüsse leitest du aus dem ab, was erkennbar nicht zum Bewerber ' +
'passt - etwa Stellen weit unter oder weit über seinem Erfahrungsniveau, fachfremde Rollen ' +
'oder Anstellungsformen, die er offensichtlich nicht sucht. Du erfindest nichts: Jeder ' +
'Vorschlag muss sich aus den Unterlagen belegen lassen. Antworte auf Deutsch im lateinischen ' +
'Alphabet.',
},
];
const BY_KEY = new Map(DEFAULTS.map((p) => [p.key, p]));
// Default text of a prompt, or '' for an unknown key.
function defaultText(key) {
const p = BY_KEY.get(key);
return p ? p.inhalt : '';
}
function isKnownKey(key) {
return BY_KEY.has(key);
}
// Substitute {{placeholders}}. Unknown placeholders are left untouched so a typo
// in the UI shows up in the output instead of silently emptying the sentence.
function render(text, vars = {}) {
return String(text == null ? '' : text).replace(/\{\{(\w+)\}\}/g, (match, name) =>
Object.prototype.hasOwnProperty.call(vars, name) && vars[name] ? String(vars[name]) : match
);
}
// Look a prompt up in the overrides map (key -> text), falling back to the
// default, and fill its placeholders. `prompts` may be undefined — callers that
// don't pass any overrides simply get the defaults.
function get(prompts, key, vars = {}) {
const override = prompts && typeof prompts[key] === 'string' ? prompts[key].trim() : '';
return render(override || defaultText(key), vars);
}
// The list rendered on the Vorlagen page: every prompt with its current text and
// whether it deviates from the default.
function list(overrides = {}) {
return DEFAULTS.map((p) => {
const override = typeof overrides[p.key] === 'string' ? overrides[p.key].trim() : '';
return {
key: p.key,
titel: p.titel,
beschreibung: p.beschreibung,
platzhalter: p.platzhalter,
inhalt: override || p.inhalt,
standard: p.inhalt,
angepasst: Boolean(override && override !== p.inhalt.trim()),
};
});
}
module.exports = { DEFAULTS, defaultText, isKnownKey, render, get, list };
+344
View File
@@ -0,0 +1,344 @@
// Volltextsuche über Bewerbungen und ihre E-Mail-Korrespondenz (SQLite FTS5).
//
// Bisher filterte die Startseite nur clientseitig über Firma + Stelle der schon
// gerenderten Zeilen. Damit war unauffindbar, was nur in einer E-Mail steht — der
// Name der Ansprechpartnerin, ein Betreff, eine Zusage im Fließtext. Diese
// Suche indiziert daher beides.
//
// Aufbau: ein FTS5-Index aus *Fragmenten*. Ein Fragment ist entweder eine
// Bewerbung (Firma, Stelle, Notizen, Stellenbeschreibung …) oder eine einzelne
// E-Mail (Absender/Empfänger, Betreff, Text). Jedes E-Mail-Fragment trägt Firma
// und Stelle seiner Bewerbung mit — nur so findet eine Anfrage wie
// "müller bosch" die Bewerbung, obwohl der Name allein in der E-Mail und die
// Firma allein in der Bewerbung steht (FTS5 verknüpft Terme innerhalb *einer*
// Zeile mit UND).
//
// Gepflegt wird der Index von SQL-Triggern, nicht von der Anwendung: Bewerbungen
// und E-Mails werden an vielen Stellen geschrieben (Web-UI, REST-API, IMAP-Abruf,
// KI-Import), und ein vergessener Aufruf würde den Index still veralten lassen.
//
// Die Ergebnisse werden nach Bewerbung gruppiert zurückgegeben: eine Bewerbung
// ist ein Treffer, die Fragmente sagen, *wo* sie getroffen wurde ("in E-Mail von
// Frau Müller"). E-Mails ohne zugeordnete Bewerbung (Postfach) sind eigene Treffer.
// Spaltengewichte für bm25: ein Firmentreffer wiegt schwerer als ein Wort
// irgendwo im Mailtext. Reihenfolge = Spaltenreihenfolge im Index.
const GEWICHTE = [12.0, 8.0, 6.0, 4.0, 1.0]; // firma, stelle, person, betreff, text
// Beschnitt pro Mailtext: eine lange Signatur/Zitatkette bläht den Index auf,
// ohne die Suche besser zu machen.
const MAX_TEXT = 20000;
// Wie viele Bewerbungen eine Suche höchstens zurückgibt.
const LIMIT = 25;
// Markierungen um Fundstellen im Snippet. Bewusst keine HTML-Tags: die Rohtexte
// werden erst im Client escaped und die Marker danach durch <mark> ersetzt, sonst
// wäre der Snippet ein XSS-Vektor (Mailtexte sind Fremdinhalt).
const MARK_START = '\u0002';
const MARK_END = '\u0003';
// Feld-Filter, die der Nutzer tippen kann: "firma:bosch", "von:müller".
const FELD_ALIASE = {
firma: 'firma', unternehmen: 'firma',
stelle: 'stelle', job: 'stelle', position: 'stelle',
person: 'person', von: 'person', an: 'person', name: 'person', kontakt: 'person',
betreff: 'betreff', subject: 'betreff',
text: 'text', notiz: 'text', notizen: 'text', mail: 'text', inhalt: 'text',
};
// Der Bewerbungs-Teil eines Fragments (Notizen, Beschreibung, Metadaten) — als
// SQL-Ausdruck, weil ihn Trigger und Backfill gleichermaßen brauchen.
const BEWERBUNG_TEXT = `
trim(coalesce(%s.notizen, '') || ' ' || coalesce(%s.interne_notizen, '') || ' ' ||
coalesce(%s.llm_notizen, '') || ' ' || coalesce(%s.ort, '') || ' ' ||
coalesce(%s.art, '') || ' ' || coalesce(%s.status, '') || ' ' ||
coalesce(%s.labels, '') || ' ' || coalesce(%s.stellenbeschreibung, '') || ' ' ||
coalesce(%s.quelle_url, ''))
`;
const bewerbungText = (alias) => BEWERBUNG_TEXT.replace(/%s/g, alias);
// Schema + Trigger + einmaliger Backfill. Idempotent: läuft bei jedem Start.
async function ensureSchema({ exec, dbGet }) {
await exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS suche_index USING fts5(
firma, stelle, person, betreff, text,
user_id UNINDEXED,
bewerbung_id UNINDEXED,
typ UNINDEXED,
ref_id UNINDEXED,
meta UNINDEXED,
tokenize = 'unicode61 remove_diacritics 2'
)
`);
// Trigger. "typ" trennt die beiden Fragmentarten, "ref_id" zeigt auf die
// Quellzeile — beides brauchen die Trigger, um gezielt zu löschen.
await exec(`
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_ai AFTER INSERT ON bewerbungen BEGIN
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
VALUES (coalesce(new.firma, ''), coalesce(new.stelle, ''), coalesce(new.email_empfaenger, ''), '',
${bewerbungText('new')}, new.user_id, new.id, 'bewerbung', new.id, '');
END
`);
// Beim Ändern: eigenes Fragment neu aufbauen und Firma/Stelle in den
// E-Mail-Fragmenten nachziehen (sie tragen sie denormalisiert mit).
await exec(`
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_au AFTER UPDATE ON bewerbungen BEGIN
DELETE FROM suche_index WHERE typ = 'bewerbung' AND ref_id = old.id;
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
VALUES (coalesce(new.firma, ''), coalesce(new.stelle, ''), coalesce(new.email_empfaenger, ''), '',
${bewerbungText('new')}, new.user_id, new.id, 'bewerbung', new.id, '');
UPDATE suche_index SET firma = coalesce(new.firma, ''), stelle = coalesce(new.stelle, '')
WHERE typ = 'email' AND bewerbung_id = new.id;
END
`);
await exec(`
CREATE TRIGGER IF NOT EXISTS bewerbungen_suche_ad AFTER DELETE ON bewerbungen BEGIN
DELETE FROM suche_index WHERE bewerbung_id = old.id;
END
`);
// E-Mails: Firma/Stelle der zugeordneten Bewerbung wandern ins Fragment, damit
// "müller bosch" trifft. meta trägt, was das Ergebnis anzeigt (Betreff, Absender,
// Datum, Richtung) — als JSON, unindiziert.
const emailFragment = () => `
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''),
trim(coalesce(new.from_addr, '') || ' ' || coalesce(new.to_addr, '')),
coalesce(new.subject, ''), substr(coalesce(new.body_text, ''), 1, ${MAX_TEXT}),
new.user_id, new.bewerbung_id, 'email', new.id,
json_object('betreff', coalesce(new.subject, ''), 'von', coalesce(new.from_addr, ''),
'an', coalesce(new.to_addr, ''), 'datum', coalesce(new.email_date, new.created_at),
'richtung', coalesce(new.direction, ''))
FROM (SELECT 1) LEFT JOIN bewerbungen b ON b.id = new.bewerbung_id;
`;
await exec(`
CREATE TRIGGER IF NOT EXISTS emails_suche_ai AFTER INSERT ON emails BEGIN
${emailFragment()}
END
`);
// Eine E-Mail wird u. a. beim Zuordnen im Postfach aktualisiert (bewerbung_id) —
// dann muss das Fragment mitsamt Firma/Stelle neu entstehen.
await exec(`
CREATE TRIGGER IF NOT EXISTS emails_suche_au AFTER UPDATE ON emails BEGIN
DELETE FROM suche_index WHERE typ = 'email' AND ref_id = old.id;
${emailFragment()}
END
`);
await exec(`
CREATE TRIGGER IF NOT EXISTS emails_suche_ad AFTER DELETE ON emails BEGIN
DELETE FROM suche_index WHERE typ = 'email' AND ref_id = old.id;
END
`);
// Backfill: nur beim allerersten Start nach der Einführung (Index noch leer,
// Daten aber vorhanden). Danach halten die Trigger alles aktuell.
const leer = await dbGet('SELECT count(*) AS n FROM suche_index');
if (leer && leer.n === 0) {
await exec(`
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''), coalesce(b.email_empfaenger, ''), '',
${bewerbungText('b')}, b.user_id, b.id, 'bewerbung', b.id, ''
FROM bewerbungen b
`);
await exec(`
INSERT INTO suche_index (firma, stelle, person, betreff, text, user_id, bewerbung_id, typ, ref_id, meta)
SELECT coalesce(b.firma, ''), coalesce(b.stelle, ''),
trim(coalesce(e.from_addr, '') || ' ' || coalesce(e.to_addr, '')),
coalesce(e.subject, ''), substr(coalesce(e.body_text, ''), 1, ${MAX_TEXT}),
e.user_id, e.bewerbung_id, 'email', e.id,
json_object('betreff', coalesce(e.subject, ''), 'von', coalesce(e.from_addr, ''),
'an', coalesce(e.to_addr, ''), 'datum', coalesce(e.email_date, e.created_at),
'richtung', coalesce(e.direction, ''))
FROM emails e LEFT JOIN bewerbungen b ON b.id = e.bewerbung_id
`);
}
}
// ---------------------------------------------------------------------------
// Eingabe -> FTS5-MATCH-Ausdruck
// ---------------------------------------------------------------------------
// Was der Nutzer tippen darf:
// müller -> Präfixsuche über alle Felder
// "frau müller" -> Wortfolge
// firma:bosch -> nur in diesem Feld
// -zeitarbeit -> schließt Treffer aus
// Alles andere (FTS-Sonderzeichen wie * : ^ NEAR) wird entschärft: jeder Term
// geht in Anführungszeichen in die Query, damit eine Eingabe wie "c++" oder ein
// Doppelpunkt keinen Syntaxfehler auslöst statt zu suchen.
function buildMatch(roh) {
const q = String(roh == null ? '' : roh).trim();
if (!q) return null;
const TOKEN = /(-?)(?:(\w+):)?(?:"([^"]*)"|(\S+))/gu;
const positiv = [];
const negativ = [];
let m;
while ((m = TOKEN.exec(q)) !== null) {
const [, minus, feldRoh, phrase, wort] = m;
const inhalt = (phrase != null ? phrase : wort || '').trim();
// Ohne Buchstaben/Ziffern bleibt nichts Suchbares übrig (z. B. "--" oder ":").
const sauber = inhalt.replace(/"/g, ' ').trim();
if (!/[\p{L}\p{N}]/u.test(sauber)) continue;
const feld = feldRoh ? FELD_ALIASE[feldRoh.toLowerCase()] : null;
// Unbekanntes Präfix ("foo:bar") ist kein Feldfilter, sondern Suchtext.
const suchtext = feldRoh && !feld ? `${feldRoh} ${sauber}` : sauber;
const term = umlautTerm(suchtext);
// Ein Feldfilter darf auf einen geklammerten Ausdruck zeigen: {firma} : ("a"* OR "b"*)
const mitFeld = feld ? `{${feld}} : ${term}` : term;
(minus ? negativ : positiv).push(mitFeld);
}
// Eine reine Ausschlussfrage ("-zeitarbeit") hat keinen Anker — FTS5 braucht
// mindestens einen positiven Term, und "alles außer X" ist ohnehin keine Suche.
if (!positiv.length) return null;
const basis = positiv.join(' AND ');
return negativ.length ? `${basis} NOT (${negativ.join(' OR ')})` : basis;
}
// Ein Term als FTS5-Ausdruck, mit Präfixsuche ("bosch"* findet auch
// "boschgruppe" — beim Tippen der Normalfall).
//
// Dazu die deutsche Umlaut-Frage: der Index faltet Diakritika (Müller -> muller),
// deshalb findet "muller" bereits "Müller". Die ausgeschriebene Form "mueller"
// aber nicht — und genau die tippt man bei Namen ständig. Also wird zusätzlich
// die Variante mit aufgelöstem ue/oe/ae/ss gesucht, ODER-verknüpft. Falsche
// Auflösungen ("Neuenkirchen" -> "nunkirchen") laufen dabei einfach ins Leere,
// der Originalterm bleibt ja daneben stehen.
function umlautTerm(text) {
const roh = `"${text}"*`;
const variante = text
.replace(/ue/gi, 'u').replace(/oe/gi, 'o').replace(/ae/gi, 'a')
.replace(/ß/g, 'ss');
if (variante.toLowerCase() === text.toLowerCase()) return roh;
return `(${roh} OR "${variante}"*)`;
}
// ---------------------------------------------------------------------------
// Suche
// ---------------------------------------------------------------------------
const SPALTEN = ['firma', 'stelle', 'person', 'betreff', 'text'];
const FELD_LABEL = { firma: 'Firma', stelle: 'Stelle', person: 'Kontakt', betreff: 'Betreff', text: 'Text' };
// Findet Bewerbungen (und nicht zugeordnete E-Mails) zu einer Suchanfrage.
// `dbAll` ist der Query-Helfer der App, `userId` grenzt hart auf den Besitzer ein
// (der Index ist gemeinsam, die unindizierte user_id-Spalte trennt die Mandanten).
// Rückgabe: nach Score sortierte Treffer, jeder mit Fundstellen + Snippets.
async function suche(dbAll, userId, roh, limit = LIMIT) {
const match = buildMatch(roh);
if (!match || !userId) return [];
const gewichte = GEWICHTE.join(', ');
const snippets = SPALTEN
.map((_, i) => `snippet(suche_index, ${i}, '${MARK_START}', '${MARK_END}', '…', 12) AS s_${i}`)
.join(', ');
// Fragmente laden (mehrere je Bewerbung möglich), das Gruppieren macht JS —
// in SQL ginge es nur mit einem zweiten Durchlauf über die FTS-Tabelle.
let rows;
try {
rows = await dbAll(
`SELECT bewerbung_id, typ, ref_id, meta, ${snippets},
bm25(suche_index, ${gewichte}) AS score
FROM suche_index
WHERE suche_index MATCH ? AND user_id = ?
ORDER BY score
LIMIT ?`,
[match, userId, limit * 4] // Puffer: mehrere Fragmente fallen auf eine Bewerbung zusammen
);
} catch (e) {
// Eine Eingabe, die FTS5 trotz Entschärfung nicht parst, ist kein Serverfehler,
// sondern schlicht kein Treffer.
if (/fts5|syntax/i.test(e.message)) return [];
throw e;
}
if (!rows.length) return [];
// Fragmente -> Treffer je Bewerbung. Nicht zugeordnete E-Mails bleiben einzeln.
const treffer = new Map();
for (const r of rows) {
const key = r.bewerbung_id ? `b${r.bewerbung_id}` : `e${r.ref_id}`;
if (!treffer.has(key)) {
treffer.set(key, {
typ: r.bewerbung_id ? 'bewerbung' : 'email',
bewerbung_id: r.bewerbung_id || null,
email_id: r.bewerbung_id ? null : r.ref_id,
score: r.score,
fundstellen: [],
});
}
const t = treffer.get(key);
t.score = Math.min(t.score, r.score); // bm25: kleiner = besser
const fund = fundstelle(r);
if (fund) t.fundstellen.push(fund);
}
const liste = [...treffer.values()].sort((a, b) => a.score - b.score).slice(0, limit);
await ergaenzeBewerbungen(dbAll, userId, liste);
for (const t of liste) t.fundstellen = t.fundstellen.slice(0, 3);
return liste;
}
// Aus einem Fragment die beste Fundstelle bauen: die Spalte mit Markierung im
// Snippet (FTS5 liefert für Spalten ohne Treffer den Anfang des Textes zurück).
function fundstelle(row) {
for (let i = 0; i < SPALTEN.length; i++) {
const s = row[`s_${i}`];
if (!s || s.indexOf(MARK_START) === -1) continue;
const meta = row.meta ? sicherJson(row.meta) : null;
return {
quelle: row.typ, // 'bewerbung' | 'email'
feld: SPALTEN[i],
feld_label: FELD_LABEL[SPALTEN[i]],
snippet: s,
email: meta && row.typ === 'email' ? meta : null,
};
}
return null;
}
function sicherJson(s) {
try { return JSON.parse(s); } catch (e) { return null; }
}
// Snippet ohne die Fundstellen-Marker — für Verbraucher, die nichts hervorheben
// (der KI-Chat etwa: Steuerzeichen im Tool-Ergebnis würden das Modell nur irritieren).
function ohneMarker(s) {
return String(s == null ? '' : s).split(MARK_START).join('').split(MARK_END).join('');
}
// Die Anzeigedaten der getroffenen Bewerbungen nachladen (der Index hält nur
// Suchtext). Eine Abfrage für alle Treffer.
async function ergaenzeBewerbungen(dbAll, userId, liste) {
const ids = liste.filter((t) => t.bewerbung_id).map((t) => t.bewerbung_id);
if (!ids.length) return;
const rows = await dbAll(
`SELECT id, firma, stelle, status, datum, art FROM bewerbungen
WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})`,
[userId, ...ids]
);
const byId = new Map(rows.map((r) => [r.id, r]));
for (const t of liste) {
const b = t.bewerbung_id ? byId.get(t.bewerbung_id) : null;
if (b) t.bewerbung = b;
}
// Eine Bewerbung, die es nicht mehr gibt (Index hinkt hinterher), fällt raus.
for (let i = liste.length - 1; i >= 0; i--) {
if (liste[i].typ === 'bewerbung' && !liste[i].bewerbung) liste.splice(i, 1);
}
}
module.exports = {
ensureSchema, suche, buildMatch, ohneMarker,
MARK_START, MARK_END, LIMIT, FELD_ALIASE,
};
+290
View File
@@ -0,0 +1,290 @@
// Per-user job-search profile ("Suchprofil") + run queue.
//
// Background: the job search used to be two host-side cron scripts with the
// search criteria (seven cities, roles, buzzwords) hard-coded into the prompt —
// one user's profile, baked into the shell script. On a multi-user platform each
// user needs their own criteria, so the *what* now lives here, per user.
//
// Division of labour, forced by the deployment: the app runs in a container that
// has neither `claude` nor `ollama` (both live on the host), so the app cannot
// execute a search itself. Instead:
//
// app (container) -> writes a row into `suchlaeufe` (queued run)
// runner (host) -> picks queued/due runs up, executes the headless search,
// writes status + results back into the same table
//
// The runner talks to the tracker through the REST API using *that user's* API
// key, so an imported Jobangebot lands in the right account and goes through the
// same dedup/blacklist path as everything else.
//
// What the user configures (Städte, Modus, Zusatz-/Ausschlussbegriffe) and what
// is derived: the role titles and technology buzzwords still come from the user's
// own Lebenslauf (basis_dokumente, typ "Lebenslauf") — the profile steers *where*
// and sets guard rails, the CV supplies *what*.
const MODI = ['regional', 'remote', 'beides'];
// Weekdays as ISO numbers (1 = Monday … 7 = Sunday), matching what the UI shows.
const WOCHENTAGE = [
{ nr: 1, kurz: 'Mo' }, { nr: 2, kurz: 'Di' }, { nr: 3, kurz: 'Mi' },
{ nr: 4, kurz: 'Do' }, { nr: 5, kurz: 'Fr' }, { nr: 6, kurz: 'Sa' }, { nr: 7, kurz: 'So' },
];
const DEFAULTS = {
aktiv: 0,
modus: 'regional',
staedte: [],
berufsbezeichnungen: '',
zusatz_begriffe: '',
ausschluesse: 'Zeitarbeit / Arbeitnehmerüberlassung, Personalvermittler / Headhunter',
zeitplan_tage: '1,2,3,4,5',
zeitplan_zeit: '17:00',
};
// Cap the city list: each city multiplies the number of web queries a run makes,
// and an unbounded list would turn one run into an all-night job.
const MAX_STAEDTE = 12;
function parseStaedte(raw) {
if (Array.isArray(raw)) return normalizeStaedte(raw);
if (typeof raw !== 'string' || !raw.trim()) return [];
// Accept both the stored JSON array and free text from the form ("A, B\nC").
if (raw.trim().startsWith('[')) {
try { return normalizeStaedte(JSON.parse(raw)); } catch (e) { /* fall through */ }
}
return normalizeStaedte(raw.split(/[,;\n]/));
}
function normalizeStaedte(list) {
const out = [];
for (const s of list) {
const v = String(s == null ? '' : s).trim().replace(/\s+/g, ' ');
if (!v) continue;
if (out.some((x) => x.toLowerCase() === v.toLowerCase())) continue; // case-insensitive dedup
out.push(v);
if (out.length >= MAX_STAEDTE) break;
}
return out;
}
function parseTage(raw) {
const list = String(raw || '').split(',')
.map((n) => parseInt(n, 10))
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 7);
return [...new Set(list)].sort((a, b) => a - b);
}
// "HH:MM", clamped to a valid time; anything unparseable falls back to the default.
function parseZeit(raw) {
const m = /^(\d{1,2}):(\d{2})$/.exec(String(raw || '').trim());
if (!m) return DEFAULTS.zeitplan_zeit;
const h = Math.min(23, Math.max(0, parseInt(m[1], 10)));
const min = Math.min(59, Math.max(0, parseInt(m[2], 10)));
return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
}
// A DB row (or nothing) -> the profile object the app and the runner work with.
// A user without a row is a valid state: they simply have no search configured.
function fromRow(row) {
const r = row || {};
const modus = MODI.includes(r.modus) ? r.modus : DEFAULTS.modus;
return {
aktiv: r.aktiv ? 1 : 0,
modus,
staedte: parseStaedte(r.staedte),
berufsbezeichnungen: r.berufsbezeichnungen || '',
zusatz_begriffe: r.zusatz_begriffe || '',
ausschluesse: r.ausschluesse != null ? r.ausschluesse : DEFAULTS.ausschluesse,
zeitplan_tage: parseTage(r.zeitplan_tage != null ? r.zeitplan_tage : DEFAULTS.zeitplan_tage),
zeitplan_zeit: parseZeit(r.zeitplan_zeit != null ? r.zeitplan_zeit : DEFAULTS.zeitplan_zeit),
};
}
// Form body -> storable values. `sanitize` is the app's XSS sanitizer, injected so
// this module stays free of app dependencies.
function fromForm(body, sanitize = (v) => v) {
const b = body || {};
const modus = MODI.includes(b.modus) ? b.modus : DEFAULTS.modus;
const tage = Array.isArray(b.tage) ? b.tage : (b.tage ? [b.tage] : []);
return {
aktiv: (b.aktiv === '1' || b.aktiv === 'on' || b.aktiv === true) ? 1 : 0,
modus,
staedte: parseStaedte(b.staedte).map((s) => sanitize(s)),
berufsbezeichnungen: sanitize(String(b.berufsbezeichnungen || '').trim()),
zusatz_begriffe: sanitize(String(b.zusatz_begriffe || '').trim()),
ausschluesse: sanitize(String(b.ausschluesse || '').trim()),
zeitplan_tage: parseTage(tage.join(',')),
zeitplan_zeit: parseZeit(b.zeitplan_zeit),
};
}
// Values as they go into the DB (arrays serialized).
function toRow(profil) {
return {
aktiv: profil.aktiv ? 1 : 0,
modus: profil.modus,
staedte: JSON.stringify(profil.staedte || []),
berufsbezeichnungen: profil.berufsbezeichnungen || '',
zusatz_begriffe: profil.zusatz_begriffe || '',
ausschluesse: profil.ausschluesse || '',
zeitplan_tage: (profil.zeitplan_tage || []).join(','),
zeitplan_zeit: profil.zeitplan_zeit || DEFAULTS.zeitplan_zeit,
};
}
// Is the profile usable for a run? A regional search without a single city would
// otherwise send the model off to search "everywhere", which is exactly the
// unbounded run we want to avoid.
function validate(profil) {
const fehler = [];
if (!MODI.includes(profil.modus)) fehler.push('Ungültiger Modus.');
if (profil.modus !== 'remote' && !(profil.staedte || []).length) {
fehler.push('Für die regionale Suche muss mindestens eine Stadt angegeben sein.');
}
if (profil.aktiv && !(profil.zeitplan_tage || []).length) {
fehler.push('Für den Zeitplan muss mindestens ein Wochentag gewählt sein.');
}
return fehler;
}
// Is a scheduled run due? `letzterLauf` is the ISO timestamp of the last run that
// the schedule triggered (manual runs do not count, so a manual test does not
// swallow the day's scheduled run). Due means: today is one of the chosen days,
// the configured time has passed, and no scheduled run has happened since that
// time today.
function istFaellig(profil, jetzt, letzterLauf) {
if (!profil.aktiv) return false;
const tage = profil.zeitplan_tage || [];
if (!tage.length) return false;
const iso = jetzt.getDay() === 0 ? 7 : jetzt.getDay(); // JS: 0=So -> ISO 7
if (!tage.includes(iso)) return false;
const [h, m] = profil.zeitplan_zeit.split(':').map(Number);
const faelligAb = new Date(jetzt);
faelligAb.setHours(h, m, 0, 0);
if (jetzt < faelligAb) return false; // today's slot not reached yet
if (!letzterLauf) return true;
const letzte = new Date(letzterLauf);
if (Number.isNaN(letzte.getTime())) return true;
return letzte < faelligAb; // already ran for this slot?
}
// Build the instruction the headless search agent gets. Everything user-specific
// is interpolated here, so the skill itself no longer carries anyone's cities.
// `profilText` is the user's CV/profile text (from their Lebenslauf template) —
// the roles and technologies to search for are derived from it, not configured.
function buildPrompt(profil, profilText) {
const teile = [];
teile.push(
'Führe eine IT-/Fach-Stellensuche vollständig autonom im Hintergrund aus, ohne jede Rückfrage. ' +
'Nutze dafür den Stellensuche-Skill (Quellen-Politik, Dedup, Blacklist, Volltext-Beschreibung).'
);
if (profil.modus === 'regional') {
teile.push(
`Suche AUSSCHLIESSLICH Stellen mit Arbeitsort in diesen Städten: ${profil.staedte.join(', ')} ` +
`(Priorität in dieser Reihenfolge, die erste ist der Wohnort). Stellen außerhalb dieser Städte ` +
`verwerfen — auch das übrige Umland und reines Homeoffice/Remote ohne Sitz in einer dieser Städte. ` +
`Sitzt der Arbeitgeber in einer dieser Städte und bietet zusätzlich Homeoffice, ist die Stelle zulässig. ` +
`Setze IMMER labels=["Regional"].`
);
} else if (profil.modus === 'remote') {
teile.push(
'Suche AUSSCHLIESSLICH Stellen, die 100 % REMOTE / vollständig im Homeoffice und deutschlandweit ' +
'ortsunabhängig ausübbar sind (Anstellung in Deutschland). Firmensitz egal. Verwirf Präsenz-, Hybrid- ' +
'und ortsgebundene Stellen sowie vages „Homeoffice möglich" ohne klare 100-%-Remote-Zusage; bestätige ' +
'die Remote-Regelung live im Anzeigentext per WebFetch. Setze IMMER labels=["Remote"].'
);
} else {
teile.push(
`Suche in ZWEI Durchgängen: (1) regional mit Arbeitsort ausschließlich in ${profil.staedte.join(', ')} ` +
`(Priorität in dieser Reihenfolge) — diese Treffer mit labels=["Regional"]; (2) deutschlandweit Stellen, ` +
`die 100 % REMOTE / vollständig im Homeoffice ausübbar sind (live per WebFetch bestätigen) — diese ` +
`Treffer mit labels=["Remote"]. Alles, was in keinen der beiden Durchgänge fällt, verwerfen.`
);
}
// Die Berufsbezeichnungen sind die eigentlichen Suchanfragen — sie entscheiden,
// welche Anzeigen überhaupt gefunden werden. Sind sie gesetzt, sucht der Agent
// danach, statt sich die Titel bei jedem Lauf neu aus dem Lebenslauf zu
// improvisieren (was der Nutzer weder sieht noch steuern kann). Das Profil
// liefert dann nur noch die Technologien zur Gewichtung.
if (profil.berufsbezeichnungen) {
teile.push(
`Suche gezielt nach Stellen mit diesen Berufsbezeichnungen (das sind deine Suchanfragen, jede einzeln ` +
`abfragen): ${profil.berufsbezeichnungen}. Nahe Schreibweisen und gängige Varianten desselben Titels ` +
`(m/w/d, "IT-" davor, Singular/Plural, englische Entsprechung) darfst du ergänzen; auf fachfremde Rollen ` +
`ausweichen darfst du NICHT.`
);
}
if (profilText && profilText.trim()) {
teile.push(
(profil.berufsbezeichnungen
? 'Technologie-Buzzwords und die Gewichtung der Treffer leitest du AUS DEM FOLGENDEN PROFIL DES ' +
'BEWERBERS ab (die Suchtitel stehen oben und gelten) — gewichte Treffer höher, je mehr aus dem ' +
'Profil passt:'
: 'Leite Rollenbezeichnungen (Query-Varianten) und Technologie-Buzzwords AUS DEM FOLGENDEN PROFIL DES ' +
'BEWERBERS ab — fasse die Rolle breit (dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln) und ' +
'gewichte Treffer höher, je mehr aus dem Profil passt:') +
'\n\n--- PROFIL ---\n' +
profilText.trim().slice(0, 6000) +
'\n--- ENDE PROFIL ---'
);
} else if (profil.berufsbezeichnungen) {
teile.push(
'Es ist kein Lebenslauf/Profil hinterlegt — halte dich strikt an die oben genannten Berufsbezeichnungen.'
);
} else {
teile.push(
'ACHTUNG: Es ist kein Lebenslauf/Profil hinterlegt. Leite Rollen und Suchbegriffe aus den bereits ' +
'erfassten Bewerbungen des Benutzers ab (GET /applications) und halte die Suche eng an deren Stellentiteln.'
);
}
if (profil.zusatz_begriffe) {
teile.push(`Zusätzlich ausdrücklich berücksichtigen: ${profil.zusatz_begriffe}.`);
}
if (profil.ausschluesse) {
teile.push(`Ausschlusskriterien — solche Treffer verwerfen: ${profil.ausschluesse}.`);
}
teile.push(
'Fokussiere auf Original-Stellenanzeigen echter Arbeitgeber, bevorzugt direkt auf deren Karriereseite ' +
'(Firmen recherchieren, Karriereseite per WebFetch prüfen) — nicht nur breit ausgeschriebene Board-Anzeigen.'
);
teile.push(
'Pflege JEDEN neuen, live per WebFetch verifizierten Treffer sofort selbst als Jobangebot ein ' +
'(POST /joboffers, external_id DETERMINISTISCH aus firma+stelle mit Präfix "stellensuche-", NICHT aus einer ' +
'flüchtigen Board-ID, damit der Server-Upsert statt einer Dublette greift; art=Firmenwebsite wo möglich; die ' +
'VOLLSTÄNDIGE Stellenausschreibung als beschreibung; kontakt_email nur wenn belegt). Beachte die Dedup gegen ' +
'bereits erfasste Bewerbungen/Jobangebote und die Blacklist. Sperre jeden echten Neuimport (action:created) ' +
'danach sofort per POST /joboffers/blacklist (typ=firma_stelle, ort falls bekannt); bei 409 oder bloßem ' +
'updated NICHT blacklisten und NICHT mit force erzwingen.'
);
teile.push(
'Stelle am Ende KEINE Frage. Gib als LETZTE Zeile exakt diese Bilanz aus (nur Zahlen):\n' +
'ERGEBNIS neu=<X> dubletten=<Y> verworfen=<Z>'
);
return teile.join('\n\n');
}
// The runner reports back "ERGEBNIS neu=1 dubletten=2 verworfen=3"; pull the
// numbers out of whatever the agent printed. Missing counts read as 0.
function parseErgebnis(text) {
const m = /ERGEBNIS\s+neu=(\d+)\s+dubletten=(\d+)\s+verworfen=(\d+)/i.exec(String(text || ''));
if (!m) return null;
return { neu: Number(m[1]), dubletten: Number(m[2]), verworfen: Number(m[3]) };
}
module.exports = {
MODI, WOCHENTAGE, DEFAULTS, MAX_STAEDTE,
fromRow, fromForm, toRow, validate, istFaellig, buildPrompt, parseErgebnis,
parseStaedte, parseTage, parseZeit,
};
+92
View File
@@ -0,0 +1,92 @@
// Web-Recherche für den KI-Chat (Ollama Web Search API).
//
// Der Chat-Assistent kennt bisher nur, was in der Datenbank steht. Für Fragen
// wie „Was muss ich über die Firma wissen?" fehlt ihm alles, was nicht in der
// Stellenbeschreibung steht — Größe, Produkte, News, Kultur. Diese beiden
// Funktionen geben ihm einen Blick nach draußen:
//
// suche(query) -> Trefferliste (Titel, URL, Auszug)
// seiteLesen(url) -> Volltext einer Seite (z. B. die „Über uns"-Seite)
//
// Beides läuft über ollama.com und authentifiziert sich mit demselben
// OLLAMA_API_KEY, den der Benutzer für Chat und Dokumentenerzeugung hinterlegt
// hat — es ist also keine zusätzliche Konfiguration nötig. Anders als /api/chat
// ist die Websuche ein reiner Cloud-Dienst: Sie hängt NICHT an OLLAMA_HOST.
// Wer lokal gegen http://localhost:11434 chattet, aber keinen ollama.com-Key
// hinterlegt hat, hat schlicht keine Websuche (isConfigured() -> false).
const config = require('./config');
const HOST = 'https://ollama.com';
const TIMEOUT_MS = 30000;
// Antworten landen im Kontextfenster des Modells und werden bei jeder weiteren
// Runde erneut mitgeschickt. Deshalb hart deckeln: lieber knappe, brauchbare
// Auszüge als eine komplette Seite, die den Chat langsam und teuer macht.
const MAX_SNIPPET = 1200;
const MAX_SEITE = 6000;
function isConfigured() {
return Boolean(config.ollama().apiKey);
}
async function post(pfad, body) {
const { apiKey } = config.ollama();
if (!apiKey) throw new Error('Websuche nicht verfügbar: kein Ollama-API-Schlüssel konfiguriert.');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
let res;
try {
res = await fetch(`${HOST}${pfad}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body),
signal: ctrl.signal,
});
} catch (err) {
if (err.name === 'AbortError') throw new Error('Websuche: Zeitüberschreitung.');
throw new Error(`Websuche fehlgeschlagen: ${err.message}`);
} finally {
clearTimeout(timer);
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Websuche antwortete mit ${res.status}: ${text.slice(0, 200)}`);
}
return res.json();
}
function kuerzen(text, max) {
const t = String(text || '').replace(/\s+\n/g, '\n').trim();
return t.length > max ? `${t.slice(0, max)}` : t;
}
// Volltextsuche im Web. `maxResults` deckelt Ollama selbst bei 10.
async function suche(query, maxResults = 5) {
const q = String(query || '').trim();
if (q.length < 2) return { treffer: [], hinweis: 'Suchbegriff zu kurz' };
const limit = Math.min(Math.max(Number(maxResults) || 5, 1), 10);
const data = await post('/api/web_search', { query: q, max_results: limit });
const results = Array.isArray(data && data.results) ? data.results : [];
return {
treffer: results.map((r) => ({
titel: r.title || '',
url: r.url || '',
auszug: kuerzen(r.content, MAX_SNIPPET),
})),
};
}
// Eine konkrete Seite lesen (Stellenanzeige, Karriere-/Über-uns-Seite, Presse).
async function seiteLesen(url) {
const u = String(url || '').trim();
if (!/^https?:\/\//i.test(u)) throw new Error('Ungültige URL (muss mit http:// oder https:// beginnen).');
const data = await post('/api/web_fetch', { url: u });
return {
url: u,
titel: (data && data.title) || '',
inhalt: kuerzen(data && data.content, MAX_SEITE),
};
}
module.exports = { isConfigured, suche, seiteLesen };
+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",
"docker:build": "docker build -t 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": {
"ejs": "^3.1.9",
+373 -25
View File
@@ -1,5 +1,5 @@
/* ============================================
Bewerbungs-Tracker - Custom Styles
NextJobs - Custom Styles
============================================ */
/* Base styles */
@@ -7,6 +7,290 @@
box-sizing: border-box;
}
/* ============================================
Top navigation (views/partials/header.ejs)
============================================
The bar carries no surface of its own — it takes the page background and is
held apart from the content only by a hairline (and, once scrolled, the
faintest shadow). Everything in it is quiet; the single accent is the active
item, marked by a thin rail on the bottom edge. That rail is the whole point,
since the header used to have no active state at all.
--nav-bg mirrors the page background per theme. It is what the focus rings and
the badge ring cut themselves out against — without it they would need a
hard-coded colour and would break in one of the two themes. */
:root { --nav-bg: #f9fafb; } /* gray-50, matches body */
.dark { --nav-bg: #111827; } /* gray-900, matches body */
.nav-brand:focus-visible,
.nav-item:focus-visible,
.icon-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--nav-bg), 0 0 0 4px rgba(37, 99, 235, 0.6);
}
.dark .nav-brand:focus-visible,
.dark .nav-item:focus-visible,
.dark .icon-btn:focus-visible {
box-shadow: 0 0 0 2px var(--nav-bg), 0 0 0 4px rgba(56, 189, 248, 0.7);
}
/* Hairline separation only once content actually slides underneath. */
#siteHeader.is-scrolled {
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
}
.dark #siteHeader.is-scrolled {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
letter-spacing: -0.01em;
color: rgb(71 85 105); /* slate-600 */
white-space: nowrap;
transition: color 150ms ease, background-color 150ms ease;
}
button.nav-item, .icon-btn { cursor: pointer; }
.nav-item:hover {
color: rgb(15 23 42); /* slate-900 */
background-color: rgba(15, 23, 42, 0.05);
}
.nav-item.is-active { color: rgb(15 23 42); }
.dark .nav-item { color: rgb(148 163 184); } /* slate-400 */
.dark .nav-item:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.06);
}
.dark .nav-item.is-active { color: #fff; }
/* The rail: sits on the header's bottom edge, lights up for the active section
and previews faintly on hover. */
.nav-rail {
position: absolute;
left: 0.75rem;
right: 0.75rem;
bottom: -1px;
height: 2px;
border-radius: 9999px;
background-color: transparent;
transform: scaleX(0);
transition: transform 200ms ease, background-color 200ms ease;
}
.nav-item:hover .nav-rail {
background-color: rgba(100, 116, 139, 0.35);
transform: scaleX(1);
}
.nav-item.is-active .nav-rail {
background-color: rgb(37 99 235); /* blue-600, the app's action colour */
transform: scaleX(1);
}
.dark .nav-item:hover .nav-rail { background-color: rgba(148, 163, 184, 0.4); }
.dark .nav-item.is-active .nav-rail {
background-color: rgb(56 189 248); /* sky-400 reads better on the dark page */
box-shadow: 0 0 10px rgba(56, 189, 248, 0.6);
}
/* Counters shown inline next to a nav label.
`:not(.hidden)` is deliberate: a bare `display: inline-flex` would fight
Tailwind's `.hidden` (same specificity, load order decides) and the badge would
show even at count 0. This way "hidden" always wins. */
.nav-badge:not(.hidden) {
display: inline-flex;
}
.nav-badge {
align-items: center;
justify-content: center;
min-width: 1.125rem;
height: 1.125rem;
padding: 0 0.3rem;
border-radius: 9999px;
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
color: #fff;
}
.nav-badge--red { background-color: rgb(244 63 94); } /* rose-500 */
.nav-badge--green { background-color: rgb(16 185 129); } /* emerald-500 */
/* Icon-only controls in the bar (bell, burger). */
.icon-btn {
position: relative;
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
color: rgb(71 85 105); /* slate-600 */
transition: color 150ms ease, background-color 150ms ease;
}
.icon-btn:hover {
color: rgb(15 23 42);
background-color: rgba(15, 23, 42, 0.05);
}
.dark .icon-btn { color: rgb(148 163 184); }
.dark .icon-btn:hover {
color: #fff;
background-color: rgba(255, 255, 255, 0.08);
}
/* Count badge floating on an icon button. Same `:not(.hidden)` reason as above. */
.dot-badge:not(.hidden) {
display: inline-flex;
}
.dot-badge {
position: absolute;
top: -0.125rem;
right: -0.125rem;
align-items: center;
justify-content: center;
min-width: 1.125rem;
height: 1.125rem;
padding: 0 0.25rem;
border-radius: 9999px;
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
color: #fff;
box-shadow: 0 0 0 2px var(--nav-bg); /* cut out against the bar in both themes */
}
.dot-badge--red { background-color: rgb(244 63 94); }
/* Dropdown panels. */
.dd-panel {
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
z-index: 50;
padding: 0.375rem;
border-radius: 0.75rem;
background-color: #fff;
box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.35);
border: 1px solid rgba(15, 23, 42, 0.08);
animation: dd-in 140ms ease-out;
}
.dark .dd-panel {
background-color: rgb(30 41 59); /* slate-800 */
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 40px -12px rgba(0, 0, 0, 0.6);
}
/* Menus that hang off a left-aligned nav item open to the left edge. */
nav .dd-panel { right: auto; left: 0; }
/* Auf schmalen Schirmen taugt das Ausrichten am Knopf nicht: das Panel hängt an
der Glocke, rechts von ihr stehen aber noch Konto und Burger — ein 20rem breites
Panel wächst dann nach links aus dem Bild heraus (gemessen: -51px bei 375px).
Deshalb spannt es sich hier über die Breite des Headers.
Der Trick ist `position: static` auf dem Anker: damit wird der Header (sticky =
positioniert) zum Bezugsrahmen des absolut gesetzten Panels. Das hält auch die
vertikale Lage richtig — top: 100% ist dann die Unterkante des Headers, inklusive
des Impersonation-Banners, wenn eines da ist. */
@media (max-width: 767px) {
#siteHeader .dd-anchor { position: static; }
#siteHeader .dd-panel {
left: 0.75rem;
right: 0.75rem;
width: auto; /* schlägt Tailwinds w-80 / w-60 (ID erhöht die Spezifität) */
max-width: none;
}
}
@keyframes dd-in {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.dd-panel { animation: none; }
.nav-rail { transition: none; }
}
.dd-item {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
color: rgb(51 65 85); /* slate-700 */
transition: background-color 150ms ease, color 150ms ease;
}
.dd-item:hover { background-color: rgb(241 245 249); } /* slate-100 */
.dark .dd-item { color: rgb(203 213 225); }
.dark .dd-item:hover { background-color: rgba(255, 255, 255, 0.06); }
.dd-item:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.8);
}
.dd-item.is-active { background-color: rgb(239 246 255); color: rgb(29 78 216); } /* blue-50/700 */
.dark .dd-item.is-active { background-color: rgba(56, 189, 248, 0.12); color: rgb(125 211 252); }
.dd-item--danger { color: rgb(190 18 60); } /* rose-700 */
.dd-item--danger:hover { background-color: rgb(255 241 242); }
.dark .dd-item--danger { color: rgb(253 164 175); }
.dark .dd-item--danger:hover { background-color: rgba(244, 63, 94, 0.12); }
.dd-icon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
margin-top: 0.075rem;
opacity: 0.7;
}
.dd-title {
display: block;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.35;
}
.dd-desc {
display: block;
margin-top: 0.075rem;
font-size: 0.75rem;
line-height: 1.35;
color: rgb(100 116 139); /* slate-500 */
}
.dark .dd-desc { color: rgb(148 163 184); }
.dd-item.is-active .dd-desc { color: inherit; opacity: 0.75; }
/* Mobile menu. */
.m-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.9375rem;
font-weight: 500;
color: rgb(71 85 105); /* slate-600 */
transition: background-color 150ms ease, color 150ms ease;
}
.m-item:hover { background-color: rgba(15, 23, 42, 0.05); color: rgb(15 23 42); }
.m-item.is-active {
color: rgb(29 78 216); /* blue-700 */
background-color: rgb(239 246 255); /* blue-50 */
box-shadow: inset 2px 0 0 rgb(37 99 235);
}
.dark .m-item { color: rgb(148 163 184); }
.dark .m-item:hover { background-color: rgba(255, 255, 255, 0.06); color: #fff; }
.dark .m-item.is-active {
color: #fff;
background-color: rgba(56, 189, 248, 0.12);
box-shadow: inset 2px 0 0 rgb(56 189 248);
}
.m-label {
padding: 0 0.75rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: rgb(100 116 139);
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
@@ -149,23 +433,6 @@ button.loading::after {
/* Responsive adjustments */
@media (max-width: 768px) {
/* Make filter section stack on mobile */
#filterForm {
flex-direction: column;
align-items: stretch;
}
#filterForm > div {
margin-bottom: 1rem;
}
/* Adjust buttons on mobile */
#filterForm .flex.space-x-2,
#filterForm .ml-auto,
#filterForm > div:last-child {
margin-bottom: 0 !important;
}
/* Make table scroll horizontally on mobile */
.overflow-hidden {
overflow-x: auto;
@@ -174,16 +441,97 @@ button.loading::after {
table {
min-width: 768px;
}
}
/* Adjust modal width on mobile */
.max-w-lg {
max-width: 90vw;
margin: 0 5vw;
/* ============================================
Smartphone support (< 640px)
The layout already collapses most grids via Tailwind's sm:/md: prefixes and
the header ships a burger menu. The rules below cover the few surfaces that
still assume desktop: tall modals, the 7-column dashboard grid, and a couple
of non-wrapping toolbars.
============================================ */
/* Modals: the overlay locks body scroll (overflow:hidden on <body>), so a panel
taller than the viewport must scroll. The scroll container is the INNER
wrapper, not the backdrop, and it gets a DEFINITE height:100vh + overflow-y:auto.
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) {
.fixed.inset-0 > .min-h-screen > .max-w-lg,
.fixed.inset-0 > .min-h-screen > .max-w-md {
max-width: calc(100vw - 2rem) !important;
}
}
.max-w-md {
max-width: 90vw;
margin: 0 5vw;
/* Dashboard application list: the desktop layout is a 7-column grid forced to
760px and scrolled horizontally — usable on a phone only with two-thumb
scrolling. Below md, restyle each row into a stacked card so the primary
content fits the viewport. The column header row is hidden; the cells are
re-placed via grid-template-areas (their DOM order is unrelated to their
visual position).
Die Grenze ist bewusst 767px und nicht 640px wie beim Rest: das Raster braucht
allein durch seine minmax()-Spalten rund 764px, und die Karte bekommt ihre
min-w-[760px] erst ab md (768px). Zwischen 641px und 767px — Handy quer, große
Phones — galt vorher also das Desktop-Raster ohne Not, und Stelle, Art und
Status lagen außerhalb des Sichtbaren (gemessen: 132px Überhang bei 667px). */
@media (max-width: 767px) {
.app-head {
display: none !important;
}
.app-line.app-grid {
grid-template-columns: 1.25rem 1fr auto !important;
grid-template-areas:
"chevron firma actions"
"chevron stelle actions"
"chevron datum actions"
"chevron art actions"
"chevron status actions" !important;
row-gap: 0.2rem !important;
column-gap: 0.625rem !important;
padding: 0.75rem 1rem !important;
align-items: start !important;
}
.app-line.app-grid > *:nth-child(1) { grid-area: chevron; align-self: center; }
.app-line.app-grid > *:nth-child(2) { grid-area: datum; }
.app-line.app-grid > *:nth-child(3) { grid-area: firma; }
.app-line.app-grid > *:nth-child(4) { grid-area: stelle; }
.app-line.app-grid > *:nth-child(5) { grid-area: art; }
.app-line.app-grid > *:nth-child(6) { grid-area: status; }
.app-line.app-grid > *:nth-child(7) { grid-area: actions; align-self: center; }
/* Let text wrap inside a card instead of truncating to the now-narrow column. */
.app-line.app-grid .truncate {
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
}
/* Enlarge the edit/delete hit targets for touch. */
.app-line.app-grid .app-actions a,
.app-line.app-grid .app-actions button {
padding: 0.5rem !important;
}
}
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="NextJobs">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#38bdf8"/>
<stop offset="1" stop-color="#2563eb"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="15" fill="url(#g)"/>
<g transform="translate(16 16) scale(1.33)" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 632 B

+519
View File
@@ -0,0 +1,519 @@
// KI-Chat client: renders messages (markdown for assistant), sends messages,
// streams assistant tokens over SSE, and handles "new chat" reset.
// Tailwind CDN is loaded via the head partial; this file only handles logic.
(function () {
const boot = window.__CHAT_BOOT__ || {};
let activeId = boot.activeId || null;
const log = document.getElementById('chatLog');
const form = document.getElementById('chatForm');
const input = document.getElementById('chatInput');
const sendBtn = document.getElementById('sendBtn');
const newThreadBtn = document.getElementById('newThreadBtn');
const threadList = document.getElementById('threadList');
let sending = false;
// ----- helpers -----
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function scrollBottom() {
if (log) log.scrollTop = log.scrollHeight;
}
// Minimal markdown → HTML. HTML is escaped first, so model output can never
// inject markup; only the markdown tokens below are turned into tags. Link
// targets are restricted to http(s)/mailto/relative so a `javascript:` URL
// from the model cannot become a clickable XSS vector, and the URL is stripped
// of characters that could break out of the href attribute.
function link(url, label) {
const safe = String(url).trim().replace(/["'<>`\\]/g, '');
return `<a href="${safe}" target="_blank" rel="noopener noreferrer">${label}</a>`;
}
function inlineFmt(t) {
// Fertige <a>-Tags werden geparkt, damit die Autolink-Regel unten nicht in
// eine bereits gebaute href hineinläuft und den Tag zerlegt.
const parked = [];
const park = (html) => `\u0000${parked.push(html) - 1}\u0000`;
let s = t
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, url) => {
const u = String(url).trim();
if (u && !/^(https?:|mailto:|\/|#)/i.test(u)) return m;
return park(link(u, label));
});
// Nackte Quellen-URLs klickbar machen — bei Web-Recherchen nennt der
// Assistent Belege oft als reine URL, nicht als Markdown-Link. Nur http(s),
// und Satzzeichen am Ende gehören zum Satz, nicht zur Adresse.
s = s.replace(/https?:\/\/[^\s<>"'`\u0000]+/g, (u) => {
const m = u.match(/[.,;:!?)\]]+$/);
const rein = m ? u.slice(0, -m[0].length) : u;
return park(link(rein, rein)) + (m ? m[0] : '');
});
return s.replace(/\u0000(\d+)\u0000/g, (m, i) => parked[Number(i)]);
}
// Trennzeile einer Markdown-Tabelle: |---|:---:|---:| (mind. zwei Striche je
// Spalte, Doppelpunkte für die Ausrichtung).
function isTableSeparator(line) {
return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
}
function tableCells(line) {
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
}
function alignOf(sep) {
const links = sep.startsWith(':');
const rechts = sep.endsWith(':');
if (links && rechts) return 'center';
if (rechts) return 'right';
return null; // linksbündig ist der Standard, kein style nötig
}
function renderMarkdown(src) {
const esc = escapeHtml(src == null ? '' : String(src)).replace(/\r\n/g, '\n');
const lines = esc.split('\n');
let html = '';
let i = 0;
let para = [];
const flushPara = () => {
if (para.length) html += '<p>' + inlineFmt(para.join(' ')) + '</p>';
para = [];
};
while (i < lines.length) {
const line = lines[i];
if (/^```/.test(line)) { // fenced code block
flushPara();
const code = [];
i++;
while (i < lines.length && !/^```/.test(lines[i])) { code.push(lines[i]); i++; }
i++; // closing fence
html += '<pre><code>' + code.join('\n') + '</code></pre>';
continue;
}
const hm = line.match(/^(#{1,6})\s+(.*)$/);
if (hm) {
flushPara();
const lvl = Math.min(hm[1].length, 3);
html += '<h' + lvl + '>' + inlineFmt(hm[2].trim()) + '</h' + lvl + '>';
i++; continue;
}
if (/^\s*[-*]\s+/.test(line)) { // bullet list
flushPara();
const items = [];
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
items.push('<li>' + inlineFmt(lines[i].replace(/^\s*[-*]\s+/, '')) + '</li>');
i++;
}
html += '<ul>' + items.join('') + '</ul>';
continue;
}
if (/^\s*\d+\.\s+/.test(line)) { // numbered list
flushPara();
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push('<li>' + inlineFmt(lines[i].replace(/^\s*\d+\.\s+/, '')) + '</li>');
i++;
}
html += '<ol>' + items.join('') + '</ol>';
continue;
}
// Tabelle (GFM): Kopfzeile + Trennzeile (|---|:--:|), dann Datenzeilen.
// Das Modell nutzt Tabellen gern für Anforderung/Erfahrung-Vergleiche;
// ohne diesen Zweig landeten sie als Pipe-Wüste in einem Absatz.
if (line.indexOf('|') >= 0 && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
flushPara();
const kopf = tableCells(line);
const ausricht = tableCells(lines[i + 1]).map(alignOf);
i += 2;
const zeilen = [];
while (i < lines.length && lines[i].indexOf('|') >= 0 && !/^\s*$/.test(lines[i])) {
zeilen.push(tableCells(lines[i]));
i++;
}
const zelle = (tag, inhalt, idx) => {
const a = ausricht[idx];
return '<' + tag + (a ? ' style="text-align:' + a + '"' : '') + '>' + inlineFmt(inhalt || '') + '</' + tag + '>';
};
let t = '<div class="chat-md-table"><table><thead><tr>';
kopf.forEach((c, idx) => { t += zelle('th', c, idx); });
t += '</tr></thead><tbody>';
for (const z of zeilen) {
t += '<tr>';
// An der Kopfbreite ausrichten: fehlende Zellen leer, überzählige raus —
// sonst zerreißt eine krumme Zeile das Tabellenraster.
for (let c = 0; c < kopf.length; c++) t += zelle('td', z[c], c);
t += '</tr>';
}
html += t + '</tbody></table></div>';
continue;
}
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) { // Trennlinie
flushPara();
html += '<hr>';
i++; continue;
}
if (/^\s*$/.test(line)) { flushPara(); i++; continue; }
para.push(line.trim());
i++;
}
flushPara();
return html;
}
function clearLogToEmpty(msg) {
if (!log) return;
log.innerHTML =
'<div class="h-full flex items-center justify-center text-gray-400 text-sm px-6">' +
escapeHtml(msg) + '</div>';
}
// Build a message row. Assistant content is markdown-rendered + left aligned;
// user content is escaped plain text with preserved line breaks.
function buildRow(role, contentHtml) {
const wrap = document.createElement('div');
wrap.className = 'flex ' + (role === 'user' ? 'justify-end' : 'justify-start');
const bubble = document.createElement('div');
bubble.className = 'max-w-[85%] rounded-lg px-3 py-2 text-sm text-left ' +
(role === 'user'
? 'bg-blue-600 text-white whitespace-pre-wrap break-words'
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100 chat-md');
bubble.innerHTML = contentHtml;
wrap.appendChild(bubble);
return { wrap, bubble };
}
function addMessage(role, content) {
if (log && log.querySelector('.h-full')) log.innerHTML = '';
const html = role === 'user' ? escapeHtml(content) : renderMarkdown(content);
const { wrap, bubble } = buildRow(role, html);
log.appendChild(wrap);
scrollBottom();
return bubble;
}
function typingIndicator() {
if (log && log.querySelector('.h-full')) log.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'flex justify-start';
wrap.id = 'typing';
wrap.innerHTML = '<div class="rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 flex gap-1">' +
'<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span></div>';
log.appendChild(wrap);
scrollBottom();
return wrap;
}
// Titel des aktiven Threads in der Seitenleiste setzen (kein innerHTML: der
// Titel kommt aus einer Modellantwort).
function setThreadTitle(titel) {
if (!titel || !activeId) return;
const li = threadList && threadList.querySelector('li[data-thread-li="' + activeId + '"]');
const titleEl = li && li.querySelector('.threadLink .font-medium');
if (titleEl && titel !== titleEl.textContent) titleEl.textContent = titel;
}
function prependThread(id, titel) {
if (!threadList) return;
const empty = threadList.querySelector('li.text-center');
if (empty) empty.remove();
const li = document.createElement('li');
li.setAttribute('data-thread-li', id);
li.innerHTML =
'<div class="flex items-center group">' +
'<a href="/chat?thread=' + encodeURIComponent(id) + '" data-thread="' + id +
'" class="threadLink flex-1 min-w-0 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/40">' +
'<div class="truncate font-medium">' + escapeHtml(titel || '(neuer Chat)') + '</div>' +
'<div class="text-[11px] text-gray-400">gerade</div>' +
'</a>' +
'<button type="button" data-rename-thread="' + id +
'" class="threadRename shrink-0 p-1.5 rounded text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 opacity-0 group-hover:opacity-100 focus:opacity-100" title="Namen ändern" aria-label="Namen ändern">' +
'<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>' +
'</svg>' +
'</button>' +
'<button type="button" data-delete-thread="' + id +
'" class="threadDelete shrink-0 mr-2 p-1.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 opacity-0 group-hover:opacity-100 focus:opacity-100" title="Verlauf löschen" aria-label="Verlauf löschen">' +
'<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 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"></path>' +
'</svg>' +
'</button>' +
'</div>';
threadList.insertBefore(li, threadList.firstChild);
}
// ----- "Neuer Chat": reset client-side to a fresh empty conversation -----
function resetToNewChat() {
activeId = null;
document.getElementById('activeThread').value = '';
clearLogToEmpty('Stelle eine Frage zu deinen Bewerbungen.');
if (input) { input.value = ''; input.disabled = false; input.style.height = 'auto'; input.focus(); }
if (sendBtn) sendBtn.disabled = false;
// deselect sidebar rows
threadList && threadList.querySelectorAll('.bg-blue-50, .dark\\:bg-gray-700').forEach((el) => {
el.classList.remove('bg-blue-50', 'dark:bg-gray-700');
});
history.replaceState(null, '', '/chat');
}
async function createThread() {
const res = await fetch('/chat/api/threads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ titel: '' }),
});
if (!res.ok) throw new Error('Thread konnte nicht angelegt werden.');
const data = await res.json();
activeId = data.id;
document.getElementById('activeThread').value = activeId;
prependThread(activeId, data.titel);
history.replaceState(null, '', '/chat?thread=' + activeId);
return activeId;
}
// Parse the SSE response body stream; call onEvent with each parsed data object.
async function consumeSSE(res, onEvent) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep;
while ((sep = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
for (const line of block.split('\n')) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
try { onEvent(JSON.parse(payload)); } catch (e) { /* ignore */ }
}
}
}
}
async function sendMessage(text) {
if (sending || !text.trim()) return;
sending = true;
sendBtn.disabled = true;
if (!activeId) {
try { await createThread(); }
catch (e) { sending = false; sendBtn.disabled = false; alert(e.message); return; }
}
addMessage('user', text);
input.value = '';
input.style.height = 'auto';
const typing = typingIndicator();
let assistantBubble = null;
let assistantText = '';
try {
const res = await fetch('/chat/api/threads/' + activeId + '/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text }),
});
if (!res.ok || !res.body) {
let msg = 'Senden fehlgeschlagen (' + res.status + ')';
try { const j = await res.json(); msg = j.error || msg; } catch (e) {}
throw new Error(msg);
}
await consumeSSE(res, (ev) => {
if (ev.type === 'tool') {
// Tool lookups run before the first text token; surface a muted hint
// inside the typing indicator so the user sees activity.
const typingEl = document.getElementById('typing');
if (typingEl && !assistantBubble) {
const label = ev.label || ev.name || 'nachschlagen…';
typingEl.innerHTML = '<div class="rounded-lg px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-400 text-sm">' +
'🔍 ' + escapeHtml(label) + '</div>';
}
scrollBottom();
} else if (ev.type === 'token') {
if (!assistantBubble) {
if (typing) typing.remove();
assistantBubble = addMessage('assistant', '');
}
assistantText += ev.content;
assistantBubble.innerHTML = renderMarkdown(assistantText);
scrollBottom();
} else if (ev.type === 'done') {
if (assistantBubble && ev.content) {
assistantBubble.innerHTML = renderMarkdown(ev.content);
} else if (!assistantBubble) {
if (typing) typing.remove();
addMessage('assistant', ev.content || '(keine Antwort)');
}
// Live-update the auto-generated thread title in the sidebar.
setThreadTitle(ev.titel);
scrollBottom();
} else if (ev.type === 'titel') {
// Kommt nach 'done' nach: der vom Modell formulierte Titel löst den
// vorläufigen (abgeschnittene erste Nachricht) in der Seitenleiste ab.
setThreadTitle(ev.titel);
} else if (ev.type === 'error') {
if (typing) typing.remove();
if (!assistantBubble) addMessage('assistant', 'Fehler: ' + ev.message);
else assistantBubble.innerHTML = renderMarkdown(assistantText + '\n\n_Fehler: ' + ev.message + '_');
}
});
} catch (err) {
if (typing) typing.remove();
addMessage('assistant', 'Fehler: ' + (err.message || 'unbekannt'));
} finally {
if (typing && typing.parentNode) typing.remove();
sending = false;
sendBtn.disabled = false;
input.focus();
}
}
// ----- wiring -----
if (input) {
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 160) + 'px';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(input.value);
}
});
}
if (form) {
form.addEventListener('submit', (e) => {
e.preventDefault();
sendMessage(input.value);
});
}
if (newThreadBtn) {
newThreadBtn.addEventListener('click', resetToNewChat);
}
// Rename a thread inline: replace the title with an input, save on
// Enter/blur (PATCH), cancel on Escape.
async function saveRename(input, link, id, original) {
const val = input.value.trim();
if (!val) { link.querySelector('.font-medium').textContent = original; return; }
try {
const res = await fetch('/chat/api/threads/' + id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ titel: val }),
});
if (!res.ok) throw new Error('Speichern fehlgeschlagen');
link.querySelector('.font-medium').textContent = val;
} catch (err) {
link.querySelector('.font-medium').textContent = original;
alert(err.message);
}
}
function startRename(li, id) {
const link = li.querySelector('.threadLink');
const titleEl = link.querySelector('.font-medium');
const original = titleEl.textContent;
const input = document.createElement('input');
input.type = 'text';
input.value = original;
input.className = 'font-medium w-full bg-transparent border border-blue-400 rounded px-1 py-0.5 text-sm text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500';
titleEl.replaceWith(input);
input.focus();
input.select();
let done = false;
const commit = () => {
if (done) return; done = true;
const title = document.createElement('div');
title.className = 'truncate font-medium';
title.textContent = input.value.trim() || original;
input.replaceWith(title);
if (input.value.trim() && input.value.trim() !== original) {
saveRename(input, link, id, original);
} else {
title.textContent = original;
}
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(); }
else if (e.key === 'Escape') { e.preventDefault(); done = true; const title = document.createElement('div'); title.className = 'truncate font-medium'; title.textContent = original; input.replaceWith(title); }
});
input.addEventListener('blur', commit);
}
if (threadList) {
threadList.addEventListener('click', (e) => {
const renameBtn = e.target.closest('[data-rename-thread]');
if (renameBtn) {
e.preventDefault();
e.stopPropagation();
const id = Number(renameBtn.getAttribute('data-rename-thread'));
const li = threadList.querySelector('li[data-thread-li="' + id + '"]');
if (li) startRename(li, id);
}
});
}
// Delete a thread from the sidebar. Event delegation: works for server-rendered
// rows and ones added client-side (prependThread).
if (threadList) {
threadList.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-delete-thread]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const id = Number(btn.getAttribute('data-delete-thread'));
if (!id || !confirm('Diesen Chat-Verlauf wirklich löschen?')) return;
try {
const res = await fetch('/chat/api/threads/' + id, { method: 'DELETE' });
if (!res.ok) throw new Error('Löschen fehlgeschlagen (' + res.status + ')');
} catch (err) {
alert(err.message);
return;
}
const li = threadList.querySelector('li[data-thread-li="' + id + '"]');
if (li) li.remove();
// If the deleted thread was active, start a fresh chat.
if (activeId === id) resetToNewChat();
// If no threads remain, show the empty placeholder.
if (!threadList.querySelector('li[data-thread-li]')) {
const empty = document.createElement('li');
empty.className = 'px-3 py-4 text-sm text-gray-400 text-center';
empty.textContent = 'Noch keine Verläufe.';
threadList.appendChild(empty);
}
});
}
// Render any pre-loaded messages (existing thread) with the same renderer.
function renderInitial() {
const blob = document.getElementById('initialMessages');
if (!blob) return;
let msgs = [];
try { msgs = JSON.parse(blob.textContent); } catch (e) { return; }
if (!Array.isArray(msgs) || !msgs.length) return;
if (log.querySelector('.h-full')) log.innerHTML = '';
msgs.forEach((m) => addMessage(m.role, m.content));
scrollBottom();
}
renderInitial();
scrollBottom();
if (input && !input.disabled) input.focus();
})();
+139 -241
View File
@@ -1,37 +1,28 @@
// ============================================
// Bewerbungs-Tracker - Client-side JavaScript
// NextJobs - Client-side JavaScript
// ============================================
// DOM Elements
const body = document.getElementById('body');
const darkModeToggle = document.getElementById('darkModeToggle');
const sunIcon = document.getElementById('sunIcon');
const moonIcon = document.getElementById('moonIcon');
// Modal Elements
const settingsModal = document.getElementById('settingsModal');
const applicationModal = document.getElementById('applicationModal');
const deleteModal = document.getElementById('deleteModal');
const pdfExportModal = document.getElementById('pdfExportModal');
// Form Elements
const settingsForm = document.getElementById('settingsForm');
const applicationForm = document.getElementById('applicationForm');
const pdfExportForm = document.getElementById('pdfExportForm');
const filterForm = document.getElementById('filterForm');
// Button Elements
const settingsBtn = document.getElementById('settingsBtn');
const addApplicationBtn = document.getElementById('addApplicationBtn');
const exportPdfBtn = document.getElementById('exportPdfBtn');
// Close Modal Buttons
const closeSettingsModal = document.getElementById('closeSettingsModal');
const closeApplicationModal = document.getElementById('closeApplicationModal');
const closeDeleteModal = document.getElementById('closeDeleteModal');
const closePdfModal = document.getElementById('closePdfModal');
const cancelSettings = document.getElementById('cancelSettings');
const cancelApplication = document.getElementById('cancelApplication');
const cancelDelete = document.getElementById('cancelDelete');
const cancelPdfExport = document.getElementById('cancelPdfExport');
@@ -42,40 +33,6 @@ let currentApplicationId = null;
let currentDeleteId = null;
let pdfLibrariesLoaded = false;
// ============================================
// Dark Mode
// ============================================
function initializeDarkMode() {
// Check localStorage for dark mode preference
const darkMode = localStorage.getItem('darkMode');
// The `dark` class must live on <html> so that Tailwind's `.dark .dark:*`
// selectors apply to the <body> itself, not just its descendants.
if (darkMode === 'enabled' || (!darkMode && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
sunIcon.classList.add('hidden');
moonIcon.classList.remove('hidden');
} else {
document.documentElement.classList.remove('dark');
sunIcon.classList.remove('hidden');
moonIcon.classList.add('hidden');
}
}
function toggleDarkMode() {
document.documentElement.classList.toggle('dark');
sunIcon.classList.toggle('hidden');
moonIcon.classList.toggle('hidden');
// Save preference to localStorage
if (document.documentElement.classList.contains('dark')) {
localStorage.setItem('darkMode', 'enabled');
} else {
localStorage.setItem('darkMode', 'disabled');
}
}
// ============================================
// Modal Functions
// ============================================
@@ -96,51 +53,6 @@ function resetApplicationForm() {
currentApplicationId = null;
}
// ============================================
// Settings Management
// ============================================
function loadSettings() {
fetch('/api/settings')
.then(response => response.json())
.then(settings => {
if (settings) {
document.getElementById('userName').value = settings.name || '';
document.getElementById('userAddress').value = settings.adresse || '';
document.getElementById('customerNumber').value = settings.kundennummer || '';
}
})
.catch(error => console.error('Error loading settings:', error));
}
function saveSettings(event) {
event.preventDefault();
const formData = new FormData(settingsForm);
const settings = {
name: formData.get('name'),
adresse: formData.get('adresse'),
kundennummer: formData.get('kundennummer')
};
fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
})
.then(response => response.json())
.then(data => {
if (data.success) {
hideModal(settingsModal);
// Refresh page to update settings in header
location.reload();
}
})
.catch(error => console.error('Error saving settings:', error));
}
// ============================================
// Application Management (CRUD)
// ============================================
@@ -184,7 +96,8 @@ function saveApplication(event) {
status: formData.get('status'),
notizen: formData.get('notizen'),
interne_notizen: formData.get('interne_notizen'),
kommentar: formData.get('kommentar')
kommentar: formData.get('kommentar'),
labels: formData.getAll('labels')
};
let url = '/api/bewerbungen';
@@ -299,15 +212,32 @@ function generatePDF(event) {
url += params.join('&') + '&';
}
// loadPdfLibraries() is awaited here too (not only when the modal opens) so
// the autotable plugin is guaranteed to be attached before we build the doc.
// Without this, opening the modal and submitting before the plugin script
// finished loading would reach generatePdfDocument() with doc.autoTable
// still undefined ("doc.autoTable is not a function").
Promise.all([
loadPdfLibraries(),
fetch('/api/settings').then(res => res.json()),
fetch(url).then(res => res.json())
])
.then(([settings, applications]) => {
.then(([_loaded, settings, applications]) => {
// /api/export returns an array of applications; on a server error it
// returns { error: '...' } instead. Guard so we surface a real message
// rather than crashing inside generatePdfDocument with "applications.map
// is not a function".
if (!Array.isArray(applications)) {
alert('Export fehlgeschlagen: ' + ((applications && applications.error) || 'Unbekannter Serverfehler'));
return;
}
generatePdfDocument(settings, applications, month, year);
hideModal(pdfExportModal);
})
.catch(error => console.error('Error generating PDF:', error));
.catch(error => {
console.error('Error generating PDF:', error);
alert('Export fehlgeschlagen. Bitte erneut versuchen.');
});
}
// PDF Generation with jsPDF
@@ -330,10 +260,46 @@ function generatePdfDocument(settings, applications, month, year) {
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const monthName = month ? monthNames[parseInt(month) - 1] : 'alle';
// The status history is sorted ascending, so its last entry is the most recent
// status change. The export keys date + status off that entry (falling back to
// the application's own date/status when there is no history).
const lastVerlauf = (app) => {
const v = Array.isArray(app.verlauf) ? app.verlauf : [];
return v.length ? v[v.length - 1] : null;
};
const lastStatusOf = (app) => {
const lv = lastVerlauf(app);
const s = (lv && lv.status) || app.status;
return (s && s.trim()) ? s.trim() : 'Ohne Status';
};
const effDateOf = (app) => {
const lv = lastVerlauf(app);
return new Date(lv ? lv.datum : app.datum);
};
// Imported/scraped values can contain HTML entities (e.g. "GmbH &amp; Co. KG")
// and sanitizeInput() stores "< > \" '" escaped — decode them so the PDF shows
// real characters instead of the raw entity text.
const decodeEntities = (str) => {
if (str == null) return '';
return String(str)
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)))
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&');
};
// Cleaned cell text with an em-dash fallback for missing/empty values.
const cell = (v) => decodeEntities(v).trim() || '—';
// Determine the year shown in the title. When no year was picked in the form,
// derive it from the data so the title still reads e.g. "Juni 2026".
const yearsInData = [...new Set(
applications.map((a) => new Date(a.datum).getFullYear()).filter((y) => !isNaN(y))
applications.map((a) => effDateOf(a).getFullYear()).filter((y) => !isNaN(y))
)].sort((a, b) => a - b);
const displayYear = year
|| (yearsInData.length === 1 ? String(yearsInData[0])
@@ -388,16 +354,24 @@ function generatePdfDocument(settings, applications, month, year) {
yPos += 6;
// ---- Group entries by status (order kept in sync with views/index.ejs) ----
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Einstellung', 'Absage', 'Keine Rückmeldung'];
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'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
const statusColors = {
'Entwurf': [168, 85, 247],
'Gesendet': [59, 130, 246],
'Eingangsbestätigung': [14, 165, 233],
'In Bearbeitung': [20, 184, 166],
'Interessiert': [236, 72, 153],
'Telefonat': [244, 114, 182],
'Warten auf Rückmeldung': [249, 115, 22],
'Warten auf meine Antwort': [217, 70, 239],
'Vorstellungsgespräch': [245, 158, 11],
'Vertragsverhandlung': [132, 204, 22],
'Einstellung': [34, 197, 94],
'Absage': [239, 68, 68],
'Absage von meiner Seite': [225, 29, 72],
'Keine Rückmeldung': [107, 114, 128],
'Ohne Status': [148, 163, 184]
};
@@ -406,7 +380,7 @@ function generatePdfDocument(settings, applications, month, year) {
const groups = {};
applications.forEach((app) => {
const key = (app.status && app.status.trim()) ? app.status.trim() : 'Ohne Status';
const key = lastStatusOf(app);
(groups[key] = groups[key] || []).push(app);
});
const orderedKeys = [
@@ -558,121 +532,70 @@ function generatePdfDocument(settings, applications, month, year) {
doc.setTextColor(0, 0, 0);
yPos += 12;
// One block per application — no table, so the note (which documents the
// full Verlauf) gets the entire page width and is shown completely.
orderedKeys.forEach((statusKey) => {
const group = groups[statusKey];
// ---- Compact application table (one row per application) ----
// Details such as Notizen and the full Status-Verlauf are intentionally left
// out; every row shows only the last status and the date of that status change.
sectionHeading('Bewerbungen');
// Status group heading (dark bar), kept together with its first entry
ensureSpace(40);
doc.setFillColor(55, 65, 81);
doc.rect(margin, yPos, contentWidth, 10, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.setTextColor(255, 255, 255);
doc.text(`${statusKey} (${group.length})`, margin + 3, yPos + 6.8);
yPos += 14;
const tableRows = applications
.map((app) => ({ app, date: effDateOf(app), status: lastStatusOf(app) }))
.sort((a, b) => a.date - b.date);
if (tableRows.length === 0) {
doc.setFont('helvetica', 'italic');
doc.setFontSize(10.5);
doc.setTextColor(107, 114, 128);
doc.text('Keine Bewerbungen im gewählten Zeitraum.', margin, yPos + 2);
doc.setTextColor(0, 0, 0);
group.forEach((app) => {
const datum = new Date(app.datum).toLocaleDateString('de-DE');
const firma = app.firma || '—';
const stelle = app.stelle || '—';
const art = app.art || '—';
const notizen = (app.notizen || '').trim();
// Keep the heading together with the start of its content
ensureSpace(28);
// Heading bar: Firma Stelle (left), Datum (right)
doc.setFillColor(225, 232, 240);
doc.rect(margin, yPos, contentWidth, 9, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(20, 20, 20);
const heading = doc.splitTextToSize(`${firma} ${stelle}`, contentWidth - 40)[0];
doc.text(heading, margin + 2, yPos + 6);
doc.text(datum, pageWidth - margin - 2, yPos + 6, { align: 'right' });
yPos += 9;
// Meta line: Art
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(90, 90, 90);
doc.text(`Art: ${art}`, margin + 2, yPos + 5);
yPos += 9;
// Status-Verlauf timeline (chronological status changes with comments)
const verlauf = Array.isArray(app.verlauf) ? app.verlauf : [];
if (verlauf.length) {
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.setTextColor(20, 20, 20);
ensureSpace(6);
doc.text('Status-Verlauf:', margin + 2, yPos + 4);
yPos += 6;
doc.setFontSize(9);
verlauf.forEach((v) => {
const vDatum = new Date(v.datum).toLocaleDateString('de-DE');
doc.setFont('helvetica', 'bold');
doc.setTextColor(50, 50, 50);
ensureSpace(5);
doc.text(`${vDatum}${v.status || ''}`, margin + 5, yPos + 4);
yPos += 5;
const kommentar = (v.kommentar || '').trim();
if (kommentar) {
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
kommentar.split(/\r?\n/).forEach((paragraph) => {
const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 12);
lines.forEach((line) => {
ensureSpace(4.5);
doc.text(line, margin + 9, yPos + 3.5);
yPos += 4.5;
});
});
yPos += 10;
} else {
doc.autoTable({
startY: yPos,
margin: { left: margin, right: margin },
head: [['Datum', 'Firma', 'Stelle', 'Art', 'Status']],
body: tableRows.map(({ app, date, status }) => ([
isNaN(date) ? '—' : date.toLocaleDateString('de-DE'),
cell(app.firma),
cell(app.stelle),
cell(app.art),
status
])),
// Never split a row across a page boundary — a split row leaves the
// single-line cells (Datum, Art, Status) blank on the continuation page.
rowPageBreak: 'avoid',
styles: {
font: 'helvetica', fontSize: 9, cellPadding: 2.4,
textColor: [31, 41, 55], lineColor: [226, 232, 240],
lineWidth: 0.2, valign: 'middle', overflow: 'linebreak'
},
headStyles: {
fillColor: [55, 65, 81], textColor: [255, 255, 255],
fontStyle: 'bold', fontSize: 9.5, cellPadding: 2.6
},
alternateRowStyles: { fillColor: [248, 250, 252] },
columnStyles: {
0: { cellWidth: 23 },
1: { cellWidth: 41, fontStyle: 'bold' },
2: { cellWidth: 'auto' },
3: { cellWidth: 25 },
4: { cellWidth: 39, halign: 'center', fontStyle: 'bold' }
},
// Tint the Status cell in its status colour so the column reads like a badge
didParseCell: (data) => {
if (data.section === 'body' && data.column.index === 4) {
const c = colorFor(String(data.cell.raw));
data.cell.styles.textColor = c;
data.cell.styles.fillColor = [
Math.round(c[0] * 0.13 + 255 * 0.87),
Math.round(c[1] * 0.13 + 255 * 0.87),
Math.round(c[2] * 0.13 + 255 * 0.87)
];
}
}
});
yPos += 3;
yPos = doc.lastAutoTable.finalY + 10;
}
// Notizen — only rendered when the entry actually has notes
if (notizen) {
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.setTextColor(20, 20, 20);
doc.text('Notizen:', margin + 2, yPos + 4);
yPos += 6;
// Notizen body — full width, complete, with page breaks line by line
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
const lineHeight = 5;
notizen.split(/\r?\n/).forEach((paragraph) => {
const lines = doc.splitTextToSize(paragraph.length ? paragraph : ' ', contentWidth - 4);
lines.forEach((line) => {
ensureSpace(lineHeight);
doc.text(line, margin + 2, yPos + 4);
yPos += lineHeight;
});
});
}
// Separator before the next entry
yPos += 4;
ensureSpace(6);
doc.setDrawColor(210, 210, 210);
doc.line(margin, yPos, pageWidth - margin, yPos);
yPos += 8;
});
// Extra spacing after a status group
yPos += 4;
});
// Footer with confirmation
ensureSpace(20);
yPos += 6;
@@ -699,7 +622,12 @@ function loadPdfLibraries() {
// Check if already loading
if (document.getElementById('jspdf-script')) {
const checkLoaded = setInterval(() => {
if (window.jspdf && window.jspdf.jsPDF) {
// Wait for the autotable plugin too, not just jsPDF: jsPDF loads
// first (script1), the plugin second (script2). Resolving the
// moment jsPDF exists leaves doc.autoTable undefined if script2
// has not run yet.
if (window.jspdf && window.jspdf.jsPDF
&& typeof window.jspdf.jsPDF.API.autoTable === 'function') {
clearInterval(checkLoaded);
pdfLibrariesLoaded = true;
resolve();
@@ -708,13 +636,14 @@ function loadPdfLibraries() {
return;
}
// Load jsPDF from CDN
// Load jsPDF from the app's own /vendor (self-hosted) so no external
// script source has to be trusted in the Content-Security-Policy.
const script1 = document.createElement('script');
script1.id = 'jspdf-script';
script1.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js';
script1.src = '/vendor/jspdf.umd.min.js';
script1.onload = () => {
const script2 = document.createElement('script');
script2.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js';
script2.src = '/vendor/jspdf.plugin.autotable.min.js';
script2.onload = () => {
pdfLibrariesLoaded = true;
resolve();
@@ -729,18 +658,6 @@ function loadPdfLibraries() {
// Event Listeners
// ============================================
// Dark Mode Toggle
darkModeToggle.addEventListener('click', toggleDarkMode);
// Settings Modal
settingsBtn.addEventListener('click', () => {
loadSettings();
showModal(settingsModal);
});
closeSettingsModal.addEventListener('click', () => hideModal(settingsModal));
cancelSettings.addEventListener('click', () => hideModal(settingsModal));
settingsForm.addEventListener('submit', saveSettings);
// Application Modal
addApplicationBtn.addEventListener('click', openAddApplicationModal);
closeApplicationModal.addEventListener('click', () => {
@@ -764,20 +681,6 @@ closePdfModal.addEventListener('click', () => hideModal(pdfExportModal));
cancelPdfExport.addEventListener('click', () => hideModal(pdfExportModal));
pdfExportForm.addEventListener('submit', generatePDF);
// Filter Form
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
const month = document.getElementById('filterMonth').value;
const year = document.getElementById('filterYear').value;
let url = '/';
if (month || year) {
url += '?';
if (month) url += `month=${month}&`;
if (year) url += `year=${year}&`;
}
window.location.href = url;
});
// Delete buttons (event delegation). Editing opens its own page (/bewerbung/:id).
document.addEventListener('click', (e) => {
if (e.target.closest('.delete-btn')) {
@@ -788,7 +691,6 @@ document.addEventListener('click', (e) => {
// Close modals when clicking outside
document.addEventListener('click', (e) => {
if (e.target === settingsModal) hideModal(settingsModal);
if (e.target === applicationModal) {
hideModal(applicationModal);
resetApplicationForm();
@@ -800,7 +702,6 @@ document.addEventListener('click', (e) => {
// Close modals with Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!settingsModal.classList.contains('hidden')) hideModal(settingsModal);
if (!applicationModal.classList.contains('hidden')) {
hideModal(applicationModal);
resetApplicationForm();
@@ -817,7 +718,4 @@ document.getElementById('currentYear').textContent = new Date().getFullYear();
// Initialize
// ============================================
// Initialize dark mode
initializeDarkMode();
console.log('Bewerbungs-Tracker initialized');
console.log('NextJobs initialized');
File diff suppressed because one or more lines are too long
+398
View File
File diff suppressed because one or more lines are too long
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env node
// 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
// `claude` nor `ollama` nor a docker socket. The app therefore only *queues*
// runs (table suchlaeufe); this script — started by cron every few minutes —
// picks them up and executes them. It replaces the two old single-user scripts
// (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:
// 1. enqueues scheduled runs that are due (per user's Zeitplan),
// 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,
// 4. starts a container that runs the headless agent with THAT user's
// credentials:
// - BEWERBUNG_API_KEY = the user's API token (created if missing), so
// every imported Jobangebot lands in their account and passes the
// same dedup/blacklist path as the rest of the app,
// - ANTHROPIC_* = the user's own Ollama endpoint+key, so the search is
// billed to whoever requested it,
// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line
// and writes status + counts back, so the UI can show what happened.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn, spawnSync } = require('child_process');
const sqlite3 = require('sqlite3');
const suchprofil = require('../lib/suchprofil');
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 API_URL = process.env.JOBSUCHE_API_URL || 'http://localhost:4327/api/v1';
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,
// OOM); it would otherwise block the user's queue forever.
const STALE_MS = RUN_TIMEOUT_MS + 10 * 60 * 1000;
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 db = new sqlite3.Database(DB_PATH);
const all = (sql, p = []) => new Promise((res, rej) => db.all(sql, p, (e, r) => (e ? rej(e) : res(r))));
const get = (sql, p = []) => new Promise((res, rej) => db.get(sql, p, (e, r) => (e ? rej(e) : res(r))));
const run = (sql, p = []) => new Promise((res, rej) => db.run(sql, p, function (e) { e ? rej(e) : res(this); }));
function log(msg) {
console.log(`${new Date().toISOString()} ${msg}`);
}
async function cfgWert(userId, key) {
const row = await get('SELECT value FROM app_state WHERE user_id = ? AND key = ?', [userId, CFG + key]);
return row && row.value ? row.value : '';
}
// The user's API token, created on first use. Without it the agent could not
// import anything; making the user generate one by hand first would just be a
// trap ("profile active, nothing happens").
async function ensureApiKey(userId) {
const vorhanden = await cfgWert(userId, 'API_TOKEN');
if (vorhanden) return vorhanden;
const token = crypto.randomBytes(32).toString('hex');
await run(
'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ' +
'ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value',
[userId, CFG + 'API_TOKEN', token]
);
log(` API-Key für Benutzer ${userId} automatisch erzeugt.`);
return token;
}
// The CV text the search profile is derived from. Prefer the Lebenslauf, fall
// back to a Profil/Kurzprofil — the point is to give the model the user's actual
// roles and technologies rather than a hard-coded list.
async function profilText(userId) {
const rows = await all(
`SELECT typ, inhalt FROM basis_dokumente
WHERE user_id = ? AND typ IN ('Lebenslauf', 'Profil/Kurzprofil')
AND inhalt IS NOT NULL AND inhalt != ''
ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END, id ASC`,
[userId]
);
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.
async function faelligeEinreihen() {
const jetzt = new Date();
const users = await all('SELECT user_id FROM suchprofil WHERE aktiv = 1');
for (const { user_id: userId } of users) {
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
if (suchprofil.validate(profil).length) continue; // unbrauchbares Profil -> kein Zeitplan-Lauf
// Only scheduled runs count here: a manual test run must not swallow the
// day's automatic run.
const letzter = await get(
"SELECT angefordert_at FROM suchlaeufe WHERE user_id = ? AND ausloeser = 'zeitplan' ORDER BY id DESC LIMIT 1",
[userId]
);
const letzterAt = letzter ? `${letzter.angefordert_at}Z`.replace(' ', 'T') : null;
if (!suchprofil.istFaellig(profil, jetzt, letzterAt)) continue;
const offen = await get(
"SELECT id FROM suchlaeufe WHERE user_id = ? AND status IN ('angefordert','laeuft') LIMIT 1",
[userId]
);
if (offen) continue; // läuft schon etwas für diesen Benutzer
await run("INSERT INTO suchlaeufe (user_id, status, ausloeser) VALUES (?, 'angefordert', 'zeitplan')", [userId]);
log(`Zeitplan: Lauf für Benutzer ${userId} eingereiht.`);
}
}
// Runs whose process died: release them so the user is not stuck.
async function verwaisteAufraeumen() {
const res = await run(
`UPDATE suchlaeufe SET status = 'fehler', beendet_at = CURRENT_TIMESTAMP,
fehler = 'Lauf abgebrochen (Container nicht mehr vorhanden).'
WHERE status = 'laeuft'
AND gestartet_at IS NOT NULL
AND (julianday('now') - julianday(gestartet_at)) * 86400000 > ?`,
[STALE_MS]
);
if (res.changes) log(`${res.changes} verwaiste(n) Lauf/Läufe als Fehler markiert.`);
}
// Claim the oldest queued run. The conditional UPDATE is the claim: a second
// runner starting concurrently gets changes = 0 and moves on.
async function naechstenLaufClaimen() {
const kandidat = await get("SELECT * FROM suchlaeufe WHERE status = 'angefordert' ORDER BY id ASC LIMIT 1");
if (!kandidat) return null;
const res = await run(
"UPDATE suchlaeufe SET status = 'laeuft', gestartet_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'angefordert'",
[kandidat.id]
);
if (!res.changes) return null;
return get('SELECT * FROM suchlaeufe WHERE id = ?', [kandidat.id]);
}
async function laufAbschliessen(id, status, felder = {}) {
await run(
`UPDATE suchlaeufe SET status = ?, beendet_at = CURRENT_TIMESTAMP,
neu = ?, dubletten = ?, verworfen = ?, fehler = ?, log_datei = ?
WHERE id = ?`,
[status, felder.neu ?? null, felder.dubletten ?? null, felder.verworfen ?? null,
felder.fehler || null, felder.log || null, id]
);
}
// Start the headless agent inside a throwaway container for this user. Only the
// 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) => {
const args = [
'run', '--rm',
'--name', containerName,
// --network host: der Agent erreicht den Tracker (localhost:4327), das
// Internet (WebSearch/WebFetch/Arbeitsagentur) und den Ollama-Endpoint
// des Benutzers — alles über die Host-Netzwerk-Sicht.
'--network', 'host',
'--user', `${AGENT_UID}:${AGENT_GID}`,
'-e', 'HOME=/home/agent',
'-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('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let ausgabe = '';
const sammeln = (buf) => {
const s = buf.toString();
ausgabe += s;
logStream.write(s);
};
kind.stdout.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(() => {
spawnSync('docker', ['kill', containerName], { stdio: 'ignore' });
kind.kill('SIGTERM');
setTimeout(() => kind.kill('SIGKILL'), 10000);
}, RUN_TIMEOUT_MS);
kind.on('error', (err) => {
clearTimeout(timer);
resolve({ code: -1, ausgabe, fehler: `Agent-Container nicht startbar: ${err.message}` });
});
kind.on('close', (code, signal) => {
clearTimeout(timer);
const abgebrochen = signal === 'SIGTERM' || signal === 'SIGKILL';
resolve({
code,
ausgabe,
fehler: abgebrochen ? `Zeitlimit von ${RUN_TIMEOUT_MS / 1000}s überschritten.` : null,
});
});
});
}
async function laufAusfuehren(lauf) {
const userId = lauf.user_id;
const user = await get('SELECT username FROM users WHERE id = ?', [userId]);
const name = user ? user.username : `#${userId}`;
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 fehler = suchprofil.validate(profil);
if (fehler.length) {
await laufAbschliessen(lauf.id, 'fehler', { fehler: fehler.join(' ') });
log(` abgebrochen: ${fehler.join(' ')}`);
return;
}
const ollamaKey = await cfgWert(userId, 'OLLAMA_API_KEY');
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.';
await laufAbschliessen(lauf.id, 'fehler', { fehler: msg });
log(` abgebrochen: kein Ollama-Key für ${name}.`);
return;
}
const apiKey = await ensureApiKey(userId);
const modell = (await cfgWert(userId, 'OLLAMA_MODEL')) || 'glm-5.2:cloud';
const prompt = suchprofil.buildPrompt(profil, await profilText(userId));
fs.mkdirSync(LOG_DIR, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const logDatei = path.join(LOG_DIR, `jobsuche_${name}_${stamp}.log`);
const logStream = fs.createWriteStream(logDatei, { flags: 'a' });
logStream.write(`=== Suchlauf ${lauf.id} — Benutzer ${name} (${lauf.ausloeser}) ===\n`);
logStream.write(`Modus: ${profil.modus} | Städte: ${profil.staedte.join(', ') || ''}\n`);
logStream.write(`Modell: ${modell} | Container: ${containerName}\n${'-'.repeat(50)}\n`);
// Nur kuratierte Env-Vars in den Container — keine Host-Secrets.
const envObj = {
ANTHROPIC_BASE_URL: (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com',
ANTHROPIC_AUTH_TOKEN: ollamaKey,
ANTHROPIC_MODEL: modell,
ANTHROPIC_SMALL_FAST_MODEL: modell,
BEWERBUNG_API_URL: API_URL,
BEWERBUNG_API_KEY: apiKey,
JOBSUCHE_MODEL: modell,
};
const agentDir = ensureAgentDir(userId);
const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, envObj, logStream, containerName, agentDir);
const ergebnis = suchprofil.parseErgebnis(ausgabe);
logStream.write(`\n${'-'.repeat(50)}\nExit-Code: ${code}\n`);
logStream.end();
if (laufFehler) {
await laufAbschliessen(lauf.id, 'fehler', { fehler: laufFehler, log: logDatei });
log(` Lauf ${lauf.id} fehlgeschlagen: ${laufFehler}`);
return;
}
if (code !== 0 && !ergebnis) {
await laufAbschliessen(lauf.id, 'fehler', {
fehler: `Suchlauf mit Exit-Code ${code} beendet (Details im Log).`,
log: logDatei,
});
log(` Lauf ${lauf.id} fehlgeschlagen (Exit ${code}).`);
return;
}
if (!ergebnis) {
// The agent finished but did not report its tally — treat as done with unknown
// numbers rather than as a failure; the import itself already happened.
await laufAbschliessen(lauf.id, 'fertig', { neu: 0, dubletten: 0, verworfen: 0, log: logDatei });
log(` Lauf ${lauf.id} beendet, aber ohne ERGEBNIS-Zeile.`);
return;
}
await laufAbschliessen(lauf.id, 'fertig', { ...ergebnis, log: logDatei });
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() {
try {
const grenze = Date.now() - LOG_RETENTION_DAYS * 86400000;
for (const f of fs.readdirSync(LOG_DIR)) {
if (!f.startsWith('jobsuche_') || !f.endsWith('.log')) continue;
const p = path.join(LOG_DIR, f);
if (fs.statSync(p).mtimeMs < grenze) fs.unlinkSync(p);
}
} catch (e) { /* Aufräumen ist best effort */ }
}
async function main() {
if (!fs.existsSync(DB_PATH)) {
console.error(`Datenbank nicht gefunden: ${DB_PATH}`);
process.exit(1);
}
await run('PRAGMA busy_timeout = 10000'); // die App schreibt parallel
await verwaisteAufraeumen();
await faelligeEinreihen();
// Alle wartenden Läufe einsammeln (pro Benutzer steht ohnehin nur einer an)
// und mit begrenzter Parallelität abarbeiten.
const laeufe = [];
let lauf;
while ((lauf = await naechstenLaufClaimen())) laeufe.push(lauf);
if (!laeufe.length) {
log('Nichts zu tun.');
} else {
log(`${laeufe.length} Lauf/Läufe eingereiht, starte bis zu ${MAX_PARALLEL} parallel.`);
await runPool(laeufe, MAX_PARALLEL, laufAusfuehren);
}
alteLogsAufraeumen();
db.close();
}
main().catch((e) => {
console.error('Runner-Fehler:', e);
process.exit(1);
});
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Cron-Einstiegspunkt für die Jobsuche (Multi-User).
#
# Ersetzt jobsuche-cron.sh + jobsuche-remote-cron.sh: Diese beiden hatten das
# Suchprofil (sieben Städte, Rollen, Buzzwords) fest im Prompt stehen und liefen
# mit EINEM globalen API-Token aus der .env — beides ist mit der Multi-User-
# Plattform hinfällig.
#
# 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
# angeforderte Läufe abarbeitet — je Benutzer mit dessen eigenem Ollama- und
# 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):
# */5 * * * * /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
#
# Manueller Lauf: /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
set -uo pipefail
export HOME="${HOME:-/root}"
export PATH="/root/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
# Node läuft unter nvm — der Cron-Pfad kennt das Verzeichnis nicht, also nvm
# laden, damit `node` (und der Default-Node des Benutzers) gefunden wird. Ohne
# das schlug jeder Cron-Tick mit `node: command not found` fehl und die
# wartenden Suchläufe blieben ewig im Status 'angefordert' hängen.
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
PROJECT_DIR="/opt/jobbi-bewerbung"
LOG_DIR="$PROJECT_DIR/logs"
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"
LOG="$LOG_DIR/jobsuche-runner.log"
# Einzelinstanz: ein noch laufender Suchlauf darf vom nächsten Cron-Tick nicht
# ein zweites Mal gestartet werden.
exec 9>"$LOG_DIR/jobsuche-runner.lock"
if ! flock -n 9; then
exit 0
fi
cd "$PROJECT_DIR" || exit 1
# API-Erreichbarkeit prüfen — ohne den Tracker kann der Agent nichts importieren.
if ! curl -fsS --max-time 10 http://localhost:4327/api/v1/health >/dev/null 2>&1; then
echo "$(date -Is) FEHLER: NextJobs (localhost:4327) nicht erreichbar Lauf übersprungen." >> "$LOG"
exit 1
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
STATUS=$?
# Log der Runner-Steuerung kurz halten (die eigentlichen Suchlauf-Logs liegen
# je Lauf separat und werden vom Runner selbst rotiert).
if [ -f "$LOG" ] && [ "$(wc -l < "$LOG")" -gt 5000 ]; then
tail -n 2000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
exit $STATUS
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env node
// Standalone one-shot migration from the single-user schema to the multi-user
// schema. Idempotent (safe to run repeatedly). Run it once on an existing
// single-user install before (or alongside) the first boot of the multi-user
// server — the server runs the same migration at boot too, so this script is
// mainly a convenience for migrating without starting the server.
//
// node scripts/migrate-to-multiuser.js [path/to/bewerbungen.db]
//
// All existing data is assigned to the admin user (admin:admin). Change the
// admin password after first login.
const path = require('path');
const sqlite3 = require('sqlite3').verbose();
const { runMigration, ADMIN_USERNAME, ADMIN_DEFAULT_PASSWORD } = require('../lib/migrate-multiuser');
const dbPath = process.argv[2]
? path.resolve(process.argv[2])
: path.join(__dirname, '..', 'data', 'bewerbungen.db');
const db = new sqlite3.Database(dbPath);
// Promise wrappers matching the server's helpers.
const dbGet = (sql, params = []) => new Promise((resolve, reject) => db.get(sql, params, (err, row) => err ? reject(err) : resolve(row)));
const dbAll = (sql, params = []) => new Promise((resolve, reject) => db.all(sql, params, (err, rows) => err ? reject(err) : resolve(rows)));
const dbRun = (sql, params = []) => new Promise((resolve, reject) => db.run(sql, params, function (err) { err ? reject(err) : resolve({ lastID: this.lastID, changes: this.changes }); }));
(async () => {
try {
console.log(`Multi-User-Migration startet für Datenbank: ${dbPath}`);
const { adminId } = await runMigration({ db, dbAll, dbGet, dbRun });
console.log(`Multi-User-Migration abgeschlossen. Alle Daten gehören jetzt dem Benutzer „${ADMIN_USERNAME}“ (id ${adminId}).`);
console.log(`Bitte nach dem ersten Login das Passwort „${ADMIN_DEFAULT_PASSWORD}“ ändern.`);
db.close();
process.exit(0);
} catch (err) {
console.error('Multi-User-Migration fehlgeschlagen:', err);
db.close();
process.exit(1);
}
})();
+3330 -388
View File
File diff suppressed because it is too large Load Diff
+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>
+262
View File
@@ -0,0 +1,262 @@
<!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="/" 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-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Zurück zur Übersicht
</a>
<% var totalBewerbungen = users.reduce((s,u) => s + (u.anzahl_bewerbungen||0), 0); var adminCount = users.filter(u=>u.is_admin).length; %>
<div class="flex flex-wrap items-end justify-between gap-3 mb-6">
<div>
<h2 class="text-2xl font-bold flex items-center gap-2.5">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-blue-600/10 text-blue-600 dark:bg-blue-500/15 dark:text-blue-300 ring-1 ring-blue-600/15">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2a4 4 0 100-8 4 4 0 000 8z"></path>
</svg>
</span>
Benutzerverwaltung
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Konten anlegen, Passwörter zurücksetzen, ein Konto übernehmen oder Benutzer inkl. aller Daten löschen.</p>
</div>
<div class="flex flex-wrap gap-3">
<div class="rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-center shadow-sm dark:border-white/5 dark:bg-gray-800">
<div class="text-xl font-bold leading-none"><%= users.length %></div>
<div class="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mt-1">Benutzer</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-center shadow-sm dark:border-white/5 dark:bg-gray-800">
<div class="text-xl font-bold leading-none text-blue-600 dark:text-blue-300"><%= adminCount %></div>
<div class="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mt-1">Admins</div>
</div>
<div class="rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-center shadow-sm dark:border-white/5 dark:bg-gray-800">
<div class="text-xl font-bold leading-none"><%= totalBewerbungen %></div>
<div class="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400 mt-1">Bewerbungen</div>
</div>
</div>
</div>
<div class="grid gap-6">
<!-- Neuen Benutzer anlegen -->
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6">
<h3 class="text-lg font-semibold mb-1 flex items-center gap-2">
<svg class="h-5 w-5 text-blue-600 dark:text-blue-300" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
</svg>
Neuen Benutzer anlegen
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-5">Legt ein neues Konto an. Ein Admin kann ebenfalls Benutzer verwalten.</p>
<form method="POST" action="/admin/users" class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 items-end">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5" for="newUsername">Benutzername</label>
<input id="newUsername" name="username" type="text" required maxlength="64" placeholder="z. B. maxm"
class="w-full 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" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5" for="newPassword">Passwort</label>
<input id="newPassword" name="password" type="password" required placeholder="Startpasswort"
class="w-full 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" />
</div>
<div class="flex items-center h-[42px]">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input type="checkbox" name="is_admin" value="1" class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" />
Admin-Rechte
</label>
</div>
<button type="submit" class="inline-flex justify-center items-center gap-2 rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium px-4 py-2 shadow-sm">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path></svg>
Anlegen
</button>
</form>
</section>
<!-- Bestehende Benutzer -->
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-md overflow-hidden">
<div class="px-6 pt-6">
<h3 class="text-lg font-semibold flex items-center gap-2">
<svg class="h-5 w-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
</svg>
Bestehende Benutzer
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-4">Über „Anmelden als" übernimmst du ein Konto komplett — Admin-Rechte sind dabei pausiert, zurück über das Banner.</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700 bg-gray-50/60 dark:bg-white/5">
<th class="py-2.5 px-6 font-medium">Benutzername</th>
<th class="py-2.5 pr-4 font-medium">Rolle</th>
<th class="py-2.5 pr-4 font-medium text-center">Bewerbungen</th>
<th class="py-2.5 pr-4 font-medium">Erstellt</th>
<th class="py-2.5 pr-6 font-medium text-right">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<% users.forEach(function(u) { %>
<tr>
<td class="py-3 px-6 font-medium text-gray-800 dark:text-gray-100">
<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' %>">
<%= String(u.username || '?').charAt(0).toUpperCase() %>
</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><% } %>
<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 class="py-3 pr-4">
<% if (u.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">
<svg class="h-3 w-3" fill="none" stroke="currentColor" stroke-width="2.5" 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>
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>
<% } %>
</td>
<td class="py-3 pr-4 text-center text-gray-600 dark:text-gray-300"><%= u.anzahl_bewerbungen %></td>
<td class="py-3 pr-4 text-gray-500 dark:text-gray-400">
<time data-dt="<%= u.created_at %>"><%= u.created_at %></time>
</td>
<td class="py-3 pr-6">
<div class="flex flex-wrap items-center justify-end gap-1.5">
<% if (u.id !== currentUserId) { %>
<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.');">
<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>
<% } %>
<button type="button"
onclick="document.getElementById('resetForm<%= u.id %>').classList.toggle('hidden')"
class="act-btn act-blue" title="Passwort zurücksetzen">
<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>
</button>
</div>
</td>
</tr>
<tr id="resetForm<%= u.id %>" class="hidden">
<td colspan="5" class="px-6 pb-4">
<form method="POST" action="/admin/users/<%= u.id %>/reset-password" class="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 <%= u.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<%= u.id %>').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>
<span class="text-xs text-gray-500 dark:text-gray-400 self-center">Alle Sitzungen dieses Benutzers werden ungültig.</span>
</form>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
</section>
<!-- Impersonations-Audit -->
<section class="bg-white dark:bg-gray-800 rounded-xl shadow-md p-6">
<h3 class="text-lg font-semibold mb-1 flex items-center gap-2">
<svg class="h-5 w-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Impersonationen (Audit)
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Nachvollziehbar protokolliert: jeder Wechsel auf ein anderes Konto und jeder Rückwechsel.</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<th class="py-2 pr-4 font-medium">Admin</th>
<th class="py-2 pr-4 font-medium">Aktion</th>
<th class="py-2 pr-4 font-medium">Zeit</th>
</tr>
</thead>
<tbody id="auditBody" class="divide-y divide-gray-100 dark:divide-gray-700">
<tr><td colspan="3" class="py-4 text-gray-400 dark:text-gray-500">… wird geladen</td></tr>
</tbody>
</table>
</div>
</section>
</div>
</main>
<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>
(function () {
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }
// Relative Zeitangaben für die „Erstellt"-Spalte.
function fmtRel(v) {
if (!v) return '';
var dt = new Date(String(v).replace(' ', 'T') + 'Z');
if (isNaN(dt)) return v;
var diff = (Date.now() - dt.getTime()) / 1000, out;
if (diff < 60) out = 'gerade eben';
else if (diff < 3600) out = Math.floor(diff / 60) + ' Min.';
else if (diff < 86400) out = Math.floor(diff / 3600) + ' Std.';
else if (diff < 2592000) out = Math.floor(diff / 86400) + ' T';
else out = dt.toLocaleDateString('de-DE');
var full = dt.toLocaleString('de-DE');
return '<time title="' + esc(full) + '">' + out + '</time>';
}
document.querySelectorAll('time[data-dt]').forEach(function (el) { el.outerHTML = fmtRel(el.getAttribute('data-dt')); });
// Audit-Liste laden.
fetch('/admin/audit/impersonations', { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (d) {
var body = document.getElementById('auditBody');
var items = (d && d.items) || [];
if (!items.length) {
body.innerHTML = '<tr><td colspan="3" class="py-4 text-gray-400 dark:text-gray-500">Keine Impersonationen protokolliert.</td></tr>';
return;
}
body.innerHTML = items.map(function (it) {
var isStop = /stop/.test(it.action);
var badge = isStop
? '<span class="inline-flex 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">Rückwechsel</span>'
: '<span class="inline-flex px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">Anmeldung als</span>';
var ziel = isStop ? '' : ' <span class="text-gray-700 dark:text-gray-300">' + esc(it.action.replace(/^impersonate_start\s*→\s*/, '')) + '</span>';
return '<tr>'
+ '<td class="py-2 pr-4 font-medium text-gray-800 dark:text-gray-100">' + esc(it.actor || '?') + '</td>'
+ '<td class="py-2 pr-4">' + badge + ziel + '</td>'
+ '<td class="py-2 pr-4 text-gray-500 dark:text-gray-400">' + fmtRel(it.created_at) + '</td>'
+ '</tr>';
}).join('');
})
.catch(function () {
document.getElementById('auditBody').innerHTML = '<tr><td colspan="3" class="py-4 text-gray-400 dark:text-gray-500">Audit konnte nicht geladen werden.</td></tr>';
});
})();
</script>
</body>
</html>
+169
View File
@@ -0,0 +1,169 @@
<!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-4xl">
<%
// Farben synchron zur Startseite (views/index.ejs) halten.
const statusDot = {
'Entwurf': 'bg-purple-500',
'Gesendet': 'bg-indigo-500',
'Eingangsbestätigung': 'bg-blue-500',
'In Bearbeitung': 'bg-teal-500',
'Interessiert': 'bg-pink-500',
'Telefonat': 'bg-sky-500',
'Warten auf Rückmeldung': 'bg-orange-500',
'Warten auf meine Antwort': 'bg-fuchsia-500',
'Vorstellungsgespräch': 'bg-yellow-500',
'Vertragsverhandlung': 'bg-lime-500',
'Einstellung': 'bg-green-500',
'Absage': 'bg-red-500',
'Absage von meiner Seite': 'bg-rose-500',
'Keine Rückmeldung': 'bg-gray-400'
};
// Welche Seitenzahlen die Blätterleiste zeigt: erste, letzte, die aktuelle
// und je eine daneben — dazwischen Auslassungen (null). Bei wenigen Seiten
// stehen ohnehin alle da.
const zahlen = [];
for (let i = 1; i <= seiten; i++) {
const nah = Math.abs(i - seite) <= 1;
const rand = (i === 1 || i === seiten);
if (nah || rand) {
if (zahlen.length && zahlen[zahlen.length - 1] !== null && i - zahlen[zahlen.length - 1] > 1) zahlen.push(null);
zahlen.push(i);
}
}
const von = gesamt === 0 ? 0 : (seite - 1) * proSeite + 1;
const bis = Math.min(seite * proSeite, gesamt);
%>
<div class="mb-6 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<div>
<h1 class="text-2xl font-bold text-gray-800 dark:text-white">Aktivitäten</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Jede Statusänderung, über alle Bewerbungen hinweg die jüngste zuerst.
</p>
</div>
<a href="/" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">Zurück zu den Bewerbungen</a>
</div>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-300">
<% if (gesamt) { %>
<span class="font-semibold text-gray-800 dark:text-gray-100"><%= von %><%= bis %></span>
von <span class="font-semibold text-gray-800 dark:text-gray-100"><%= gesamt %></span>
<%= gesamt === 1 ? 'Eintrag' : 'Einträgen' %>
<% } else { %>
Keine Einträge
<% } %>
</p>
<% if (seiten > 1) { %>
<p class="text-sm text-gray-400 dark:text-gray-500">Seite <%= seite %> von <%= seiten %></p>
<% } %>
</div>
<% if (aktivitaet.length) { %>
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
<% aktivitaet.forEach(function (a) {
var dotCls = a.typ === 'email'
? (a.direction === 'out' ? 'bg-sky-500' : 'bg-blue-500')
: (statusDot[a.status] || 'bg-gray-300');
var titel = '', titelSub = '';
if (a.bewerbung_id) {
titel = a.firma || '(ohne Firma)';
titelSub = a.stelle || '';
} else if (a.typ === 'email') {
titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail');
} else {
titel = 'Gelöschte Bewerbung';
}
%>
<li>
<% if (a.bewerbung_id) { %>
<a href="/bewerbung/<%= a.bewerbung_id %>" class="flex items-start gap-3 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<% } else { %>
<div class="flex items-start gap-3 px-6 py-3">
<% } %>
<span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= dotCls %>"></span>
<span class="min-w-0 flex-1">
<span class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span class="text-sm font-medium <%= a.bewerbung_id ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400' %> break-words"><%= titel %></span>
<% if (titelSub) { %>
<span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= titelSub %></span>
<% } %>
</span>
<span class="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200"><%= a.status %></span>
<% if (a.datum) { %>
<span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span>
<% } %>
<% if (a.kommentar && a.kommentar.trim()) { %>
<span class="text-gray-500 dark:text-gray-400 break-words">· <%= a.kommentar %></span>
<% } %>
</span>
</span>
<% if (a.bewerbung_id) { %></a><% } else { %></div><% } %>
</li>
<% }); %>
</ul>
<% } else { %>
<div class="px-6 py-16 text-center text-sm text-gray-500 dark:text-gray-400">
Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.
</div>
<% } %>
<% if (seiten > 1) { %>
<!-- Blätterleiste: echte Links (kein JS), damit Zurück-Taste, Lesezeichen
und das Öffnen in einem neuen Tab funktionieren. -->
<nav class="flex flex-wrap items-center justify-center gap-1 px-6 py-4 border-t border-gray-200 dark:border-gray-700" aria-label="Seiten">
<% if (seite > 1) { %>
<a href="/aktivitaeten?seite=<%= seite - 1 %>" rel="prev"
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<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="M15 19l-7-7 7-7"></path></svg>
Zurück
</a>
<% } else { %>
<span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded-md border border-gray-200 dark:border-gray-700 text-gray-300 dark:text-gray-600 cursor-not-allowed">
<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="M15 19l-7-7 7-7"></path></svg>
Zurück
</span>
<% } %>
<% zahlen.forEach(function (n) { %>
<% if (n === null) { %>
<span class="px-2 text-sm text-gray-400 dark:text-gray-600">…</span>
<% } else if (n === seite) { %>
<span aria-current="page"
class="min-w-[2.25rem] text-center px-2 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white"><%= n %></span>
<% } else { %>
<a href="/aktivitaeten?seite=<%= n %>"
class="min-w-[2.25rem] text-center px-2 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"><%= n %></a>
<% } %>
<% }); %>
<% if (seite < seiten) { %>
<a href="/aktivitaeten?seite=<%= seite + 1 %>" rel="next"
class="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Weiter
<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="M9 5l7 7-7 7"></path></svg>
</a>
<% } else { %>
<span class="inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded-md border border-gray-200 dark:border-gray-700 text-gray-300 dark:text-gray-600 cursor-not-allowed">
Weiter
<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="M9 5l7 7-7 7"></path></svg>
</span>
<% } %>
</nav>
<% } %>
</section>
</main>
<%- include('partials/footer') %>
</body>
</html>
+755 -342
View File
File diff suppressed because it is too large Load Diff
+2 -21
View File
@@ -3,10 +3,10 @@
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<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="container mx-auto px-4 py-8 max-w-5xl">
<main class="flex-1 container mx-auto px-4 py-8 max-w-5xl">
<!-- Back link -->
<a href="/jobangebote" 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">
@@ -156,25 +156,6 @@
<script>
(function () {
const root = document.documentElement;
const dm = localStorage.getItem('darkMode');
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
const toggle = document.getElementById('darkModeToggle');
const sun = document.getElementById('sunIcon');
const moon = document.getElementById('moonIcon');
function sync() {
const d = root.classList.contains('dark');
if (sun) sun.classList.toggle('hidden', d);
if (moon) moon.classList.toggle('hidden', !d);
}
sync();
if (toggle) toggle.addEventListener('click', () => {
root.classList.toggle('dark');
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
sync();
});
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
+141
View File
@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
<style>
#chatLog::-webkit-scrollbar { width: 8px; }
#chatLog::-webkit-scrollbar-thumb { background: rgba(120,120,120,.4); border-radius: 4px; }
/* Typing indicator: three dots with a staggered bounce. */
.typing-dot {
width: 7px; height: 7px; border-radius: 9999px;
background: currentColor; display: inline-block;
opacity: 0.35;
animation: chat-typing 1.2s infinite ease-in-out both;
}
.typing-dot:nth-child(2) { animation-delay: 0.18s; }
.typing-dot:nth-child(3) { animation-delay: 0.36s; }
@keyframes chat-typing {
0%, 80%, 100% { transform: translateY(0); opacity: 0.35; }
40% { transform: translateY(-5px); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.typing-dot { animation: none; opacity: 0.6; }
}
/* Thread rename/delete rely on hover/focus to appear — neither exists on a
touchscreen, so the controls would be unreachable. Show them on small
(likely touch) screens. */
@media (max-width: 768px) {
.threadRename, .threadDelete { opacity: 1 !important; }
}
/* Markdown rendering inside assistant bubbles */
.chat-md { line-height: 1.5; }
.chat-md > *:first-child { margin-top: 0; }
.chat-md > *:last-child { margin-bottom: 0; }
.chat-md p { margin: 0 0 0.5rem 0; }
.chat-md h1, .chat-md h2, .chat-md h3 { font-weight: 600; margin: 0.6rem 0 0.3rem 0; line-height: 1.25; }
.chat-md h1 { font-size: 1.05rem; }
.chat-md h2 { font-size: 1rem; }
.chat-md h3 { font-size: 0.95rem; }
.chat-md ul, .chat-md ol { margin: 0 0 0.5rem 0; padding-left: 1.25rem; }
.chat-md li { margin: 0.15rem 0; }
.chat-md code { background: rgba(0,0,0,0.08); padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; }
.dark .chat-md code { background: rgba(255,255,255,0.12); }
.chat-md pre { background: rgba(0,0,0,0.08); padding: 0.6rem; border-radius: 6px; overflow-x: auto; margin: 0 0 0.5rem 0; }
.dark .chat-md pre { background: rgba(0,0,0,0.35); }
.chat-md pre code { background: none; padding: 0; }
.chat-md a { text-decoration: underline; }
.chat-md hr { border: 0; border-top: 1px solid rgba(0,0,0,0.12); margin: 0.7rem 0; }
.dark .chat-md hr { border-top-color: rgba(255,255,255,0.15); }
/* Tabellen (z. B. Anforderung/Erfahrung-Vergleiche). Der Wrapper scrollt,
damit eine breite Tabelle nicht die Chat-Blase auseinanderzieht. */
.chat-md-table { overflow-x: auto; margin: 0 0 0.5rem 0; }
.chat-md table { border-collapse: collapse; width: 100%; font-size: 0.875rem; }
.chat-md th, .chat-md td { border: 1px solid rgba(0,0,0,0.12); padding: 0.35rem 0.55rem; text-align: left; vertical-align: top; }
.chat-md th { background: rgba(0,0,0,0.04); font-weight: 600; }
.dark .chat-md th, .dark .chat-md td { border-color: rgba(255,255,255,0.15); }
.dark .chat-md th { background: rgba(255,255,255,0.06); }
</style>
</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', { hideSettings: true }) %>
<main class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-xl font-bold text-gray-800 dark:text-white">KI-Bewerbungs-Assistent</h2>
<button id="newThreadBtn"
class="px-3 py-1.5 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium">
+ Neuer Chat
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-[240px_1fr] gap-3 min-h-[360px] md:h-[calc(100vh-180px)]">
<!-- Thread sidebar -->
<aside class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden flex flex-col max-h-48 md:max-h-none">
<div class="px-3 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">Verläufe</div>
<ul id="threadList" class="overflow-y-auto flex-1 divide-y divide-gray-100 dark:divide-gray-700">
<% threads.forEach(function(t) { %>
<li data-thread-li="<%= t.id %>">
<div class="flex items-center group <%= (t.id === activeId) ? 'bg-blue-50 dark:bg-gray-700' : '' %>">
<a href="/chat?thread=<%= t.id %>"
data-thread="<%= t.id %>"
class="threadLink flex-1 min-w-0 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/40">
<div class="truncate font-medium"><%= t.titel || '(ohne Titel)' %></div>
<div class="text-[11px] text-gray-400"><%= new Date(t.updated_at).toLocaleDateString('de-DE') %></div>
</a>
<button type="button" data-rename-thread="<%= t.id %>"
class="threadRename shrink-0 p-1.5 rounded text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Namen ändern" aria-label="Namen ändern">
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</button>
<button type="button" data-delete-thread="<%= t.id %>"
class="threadDelete shrink-0 mr-2 p-1.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 opacity-0 group-hover:opacity-100 focus:opacity-100"
title="Verlauf löschen" aria-label="Verlauf löschen">
<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 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"></path>
</svg>
</button>
</div>
</li>
<% }); %>
<% if (!threads.length) { %>
<li class="px-3 py-4 text-sm text-gray-400 text-center">Noch keine Verläufe.</li>
<% } %>
</ul>
</aside>
<!-- Message area -->
<section class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex flex-col overflow-hidden h-[60vh] md:h-auto">
<div id="chatLog" class="flex-1 overflow-y-auto p-4 space-y-3 text-gray-800 dark:text-gray-100">
<% if (!messages.length) { %>
<div class="h-full flex items-center justify-center text-gray-400 text-sm px-6">
Stelle eine Frage zu deinen Bewerbungen.
</div>
<% } %>
</div>
<form id="chatForm" class="border-t border-gray-200 dark:border-gray-700 p-3 flex gap-2">
<textarea id="chatInput" rows="1" autocomplete="off"
class="flex-1 resize-none rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Frage stellen … (Enter zum Senden, Shift+Enter = Zeilenumbruch)"></textarea>
<button type="submit"
class="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium disabled:opacity-50"
id="sendBtn">Senden</button>
</form>
</section>
</div>
</main>
<script id="initialMessages" type="application/json"><%- JSON.stringify(messages.map(function(m){ return { role: m.role, content: m.content }; })).replace(/</g, '\\u003c') %></script>
<input type="hidden" id="activeThread" value="<%= activeId || '' %>">
<script>
window.__CHAT_BOOT__ = {
activeId: <%= activeId ? JSON.stringify(activeId) : 'null' %>,
needsThread: <%= activeId ? 'false' : 'true' %>
};
</script>
<script src="/js/chat.js"></script>
</body>
</html>
+496 -337
View File
@@ -3,102 +3,81 @@
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<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="container mx-auto px-4 py-8">
<!-- Search -->
<div class="flex justify-center mb-8">
<div class="relative w-full max-w-xl">
<main class="flex-1 container mx-auto px-4 py-8">
<% var zeigeDashboard = !(typeof setupWizard !== 'undefined' && setupWizard && setupWizard.aktiv); %>
<% if (!zeigeDashboard) { %>
<%- include('partials/einrichtung') %>
<% } %>
<!-- Dashboard: Markup bleibt immer im DOM, damit main.js seine Listener
binden kann (auch der "Bewerbung manuell anlegen"-Pfad des Wizards).
Bei aktivem Wizard wird der Block nur ausgeblendet. -->
<div id="dashboardWrap" class="<%= zeigeDashboard ? '' : 'hidden' %>">
<!-- Toolbar: search + actions -->
<div class="flex flex-col gap-3 mb-8 sm:flex-row sm:items-center">
<div class="relative flex-1">
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 dark:text-gray-500 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input id="firmaSearch" type="text" placeholder="Firma suchen …" autocomplete="off"
class="w-full pl-12 pr-10 py-3 rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
<input id="firmaSearch" type="search" role="searchbox" autocomplete="off" spellcheck="false"
placeholder="Suchen: Firma, Stelle, Notizen, E-Mails …"
aria-label="Bewerbungen und E-Mails durchsuchen"
aria-controls="searchResults" aria-expanded="false"
class="w-full pl-12 pr-20 py-3 rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-800 dark:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition">
<kbd id="firmaSearchHint"
class="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-block px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-[11px] font-sans text-gray-400 dark:text-gray-500 pointer-events-none">/</kbd>
<span id="firmaSearchSpinner"
class="hidden absolute right-10 top-1/2 -translate-y-1/2 w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 border-t-blue-500 animate-spin"></span>
<button id="firmaSearchClear" type="button" aria-label="Suche zurücksetzen"
class="hidden absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<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="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
</div>
<!-- Filter Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Filter</h2>
<form id="filterForm" class="flex flex-wrap gap-4 items-end">
<div class="flex-1 min-w-32">
<label for="filterMonth" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Monat
</label>
<select
id="filterMonth"
name="month"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
<option value="">-- Alle Monate --</option>
<% availableMonths.forEach(m => { %>
<option value="<%= m.month %>"><%= m.month %> - <%= m.year %></option>
<% }); %>
</select>
</div>
<div class="flex-1 min-w-32">
<label for="filterYear" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Jahr
</label>
<select
id="filterYear"
name="year"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
<option value="">-- Alle Jahre --</option>
<% const years = [...new Set(availableMonths.map(m => m.year))].sort().reverse(); %>
<% years.forEach(y => { %>
<option value="<%= y %>"><%= y %></option>
<% }); %>
</select>
</div>
<div class="flex space-x-2">
<button
type="submit"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
Filtern
</button>
<a
href="/"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Zurücksetzen
</a>
</div>
<div class="ml-auto">
<div class="flex gap-2">
<button
type="button"
id="exportPdfBtn"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors flex items-center space-x-2">
class="inline-flex items-center gap-2 rounded-full px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors bg-emerald-600 hover:bg-emerald-700">
<svg class="w-5 h-5" 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>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.9A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13v6m-3-3h6"></path>
</svg>
<span>PDF Export</span>
</button>
</div>
<div>
<button
type="button"
id="addApplicationBtn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors flex items-center space-x-2">
class="inline-flex items-center gap-2 rounded-full px-4 py-3 text-sm font-medium text-white shadow-sm transition-colors bg-blue-600 hover:bg-blue-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
<span>Bewerbung hinzufügen</span>
</button>
</div>
</form>
</div>
<!-- Suchergebnisse (Volltext über Bewerbungen + E-Mails, /api/suche) -->
<div id="searchPanel" class="hidden bg-white dark:bg-gray-800 rounded-lg shadow-md mb-8">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-3 border-b border-gray-200 dark:border-gray-700">
<p id="searchSummary" class="text-sm text-gray-600 dark:text-gray-300"></p>
<p class="text-xs text-gray-400 dark:text-gray-500">
Durchsucht Firma, Stelle, Notizen, Stellenbeschreibung und die E-Mail-Korrespondenz.
<span class="hidden sm:inline">Verfeinern: <code>firma:bosch</code>, <code>von:müller</code>, <code>"genauer wortlaut"</code>, <code>-zeitarbeit</code></span>
</p>
</div>
<ul id="searchResults" role="listbox" aria-label="Suchergebnisse" class="divide-y divide-gray-100 dark:divide-gray-700/60"></ul>
<div id="searchEmpty" class="hidden px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<p>Nichts gefunden für „<span id="searchEmptyTerm"></span>".</p>
<p class="mt-2 text-sm">Gesucht wird auch in E-Mails prüfe die Schreibweise oder suche nach einem Teilwort.</p>
</div>
<div id="searchError" class="hidden px-6 py-8 text-center text-sm text-red-600 dark:text-red-400"></div>
</div>
<!-- Upcoming appointments (from the application calendar) -->
<% if (typeof kommendeTermine !== 'undefined' && kommendeTermine && kommendeTermine.length) { %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div id="termineCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div class="flex items-center gap-2 mb-4">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Anstehende Termine</h2>
@@ -130,89 +109,85 @@
</div>
<% } %>
<!-- Statistics Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">Statistik</h2>
<!-- Gemerkte Bewerbungen — direkt unter den anstehenden Terminen -->
<%- include('partials/bewerbung-favoriten') %>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Total Applications -->
<div class="bg-blue-50 dark:bg-gray-700 rounded-lg p-4 text-center">
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400"><%= statistics.total %></div>
<div class="text-gray-600 dark:text-gray-400 mt-2">Gesamtbewerbungen</div>
</div>
<!-- By Application Type -->
<div class="bg-green-50 dark:bg-gray-700 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Nach Bewerbungsart</h3>
<div class="space-y-2">
<% if (statistics.byArt.length > 0) { %>
<% statistics.byArt.forEach(item => { %>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400"><%= item.art %></span>
<span class="font-medium text-gray-800 dark:text-white"><%= item.count %></span>
</div>
<% }); %>
<% } else { %>
<p class="text-sm text-gray-500 dark:text-gray-400">Keine Daten verfügbar</p>
<% } %>
</div>
</div>
<!-- By Status -->
<div class="bg-purple-50 dark:bg-gray-700 rounded-lg p-4">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Nach Status</h3>
<div class="space-y-2">
<% if (statistics.byStatus.length > 0) { %>
<% statistics.byStatus.forEach(item => { %>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400"><%= item.status %></span>
<span class="font-medium text-gray-800 dark:text-white"><%= item.count %></span>
</div>
<% }); %>
<% } else { %>
<p class="text-sm text-gray-500 dark:text-gray-400">Keine Daten verfügbar</p>
<% } %>
</div>
</div>
</div>
</div>
<!-- Applications Table -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-x-auto">
<table class="w-full min-w-[720px] table-fixed">
<colgroup>
<col class="w-24">
<col class="w-[22%]">
<col class="w-[28%]">
<col class="w-28">
<col class="w-36">
<col class="w-24">
</colgroup>
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Datum</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Firma</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Stelle</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Art</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<% if (applications.length > 0) { %>
<!-- Applications -->
<style>
/* Shared column grid used by the header labels and every application line */
.app-grid {
display: grid;
grid-template-columns: 1.25rem 5.5rem minmax(7rem, 1.6fr) minmax(8rem, 2fr) 5.5rem 8.5rem 5.5rem;
gap: 0 0.75rem;
align-items: center;
}
/* Smooth height collapse via the grid-template-rows 0fr → 1fr trick */
.collapsible {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.collapsible > .collapsible-inner {
overflow: hidden;
min-height: 0;
}
.collapsible.open {
grid-template-rows: 1fr;
}
.chevron {
transition: transform 0.25s ease;
}
.chevron.rotate {
transform: rotate(90deg);
}
</style>
<%
// Status-Reihenfolge synchron zu public/js/main.js halten
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Einstellung', 'Absage', 'Keine Rückmeldung'];
// 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.
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
const statusDot = {
'Entwurf': 'bg-purple-500',
'Gesendet': 'bg-indigo-500',
'Eingangsbestätigung': 'bg-blue-500',
'In Bearbeitung': 'bg-teal-500',
'Interessiert': 'bg-pink-500',
'Telefonat': 'bg-sky-500',
'Warten auf Rückmeldung': 'bg-orange-500',
'Warten auf meine Antwort': 'bg-fuchsia-500',
'Vorstellungsgespräch': 'bg-yellow-500',
'Vertragsverhandlung': 'bg-lime-500',
'Einstellung': 'bg-green-500',
'Absage': 'bg-red-500',
'Absage von meiner Seite': 'bg-rose-500',
'Keine Rückmeldung': 'bg-gray-400',
'Ohne Status': 'bg-gray-300'
};
%>
<div id="listCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-x-auto">
<!-- Kopf der Karte: Gesamtzahl der Bewerbungen. Bewusst außerhalb des
md:min-w-Rahmens unten, damit er auf dem Handy nicht mitscrollt. -->
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Bewerbungen</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span class="font-semibold text-gray-800 dark:text-gray-100"><%= statistics.total %></span>
<%= statistics.total === 1 ? 'Bewerbung' : 'Bewerbungen' %> gesamt
</p>
</div>
<div class="md:min-w-[760px]">
<!-- Column labels (desktop only — on phones the rows become stacked cards) -->
<div class="app-head app-grid px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<span></span>
<span>Datum</span>
<span>Firma</span>
<span>Stelle</span>
<span>Art</span>
<span>Status</span>
<span>Aktionen</span>
</div>
<% if (applications.length > 0) { %>
<%
const groups = {};
applications.forEach(a => {
const key = (a.status && a.status.trim()) ? a.status.trim() : 'Ohne Status';
@@ -223,261 +198,436 @@
...Object.keys(groups).filter(k => !statusOrder.includes(k))
];
%>
<div id="appList">
<% orderedKeys.forEach(statusKey => { %>
<tbody class="status-group bg-gray-100 dark:bg-gray-900/40 border-b border-gray-200 dark:border-gray-700">
<tr>
<td colspan="6" class="px-6 py-3">
<div class="flex items-center gap-3">
<span class="inline-block w-2.5 h-2.5 rounded-full <%= statusDot[statusKey] || 'bg-gray-300' %>"></span>
<section class="status-group border-t border-gray-200 dark:border-gray-700">
<!-- Klappbarer Status-Kopf (Gesendet, Eingangsbestätigung, …) -->
<button type="button" class="group-toggle w-full flex items-center gap-3 px-6 py-3 bg-gray-100 dark:bg-gray-900/40 hover:bg-gray-200/70 dark:hover:bg-gray-900/60 transition-colors text-left">
<svg class="chevron w-4 h-4 flex-shrink-0 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 class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0 <%= statusDot[statusKey] || 'bg-gray-300' %>"></span>
<span class="text-sm font-semibold uppercase tracking-wider text-gray-700 dark:text-gray-200"><%= statusKey %></span>
<span data-group-count class="text-xs font-medium px-2 py-0.5 rounded-full bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300"><%= groups[statusKey].length %></span>
</div>
</td>
</tr>
</tbody>
</button>
<div class="group-body collapsible">
<div class="collapsible-inner">
<% groups[statusKey].forEach(app => { %>
<%
const hatNotizen = app.notizen && app.notizen.trim().length > 0;
const hatInterne = app.interne_notizen && app.interne_notizen.trim().length > 0;
const hatVerlauf = app.verlauf && app.verlauf.length > 0;
const hatDetails = hatNotizen || hatInterne || hatVerlauf;
const padB = hatDetails ? 'pb-2' : 'pb-4';
%>
<tbody class="app-row bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700" data-search="<%= ((app.firma || '') + ' ' + (app.stelle || '')).toLowerCase().replace(/"/g, '') %>">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm text-gray-900 dark:text-white">
<%= new Date(app.datum).toLocaleDateString('de-DE') %>
</td>
<td class="px-6 pt-4 <%= padB %> align-top text-sm font-medium text-gray-900 dark:text-white break-words">
<%= app.firma %>
</td>
<td class="px-6 pt-4 <%= padB %> align-top text-sm text-gray-900 dark:text-white break-words">
<%= app.stelle %>
</td>
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<%= app.art || '-' %>
</td>
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm">
<span class="px-2 py-1 rounded-full text-xs font-medium
<div class="app-row border-t border-gray-100 dark:border-gray-700/60">
<!-- Eine Zeile pro Bewerbung Klick öffnet die ganze Bewerbung -->
<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 %>">
<span class="flex items-center justify-center"></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">
<span class="block truncate text-gray-900 dark:text-white" title="<%= app.stelle %>"><%= app.stelle %></span>
<% if (app.labelsArr && app.labelsArr.length) { %>
<span class="flex flex-wrap gap-1 mt-1"><%- include('partials/labelChips', { labels: app.labelsArr }) %></span>
<% } %>
</span>
<span class="min-w-0 truncate text-gray-500 dark:text-gray-400"><%= app.art || '-' %></span>
<span class="min-w-0">
<span class="inline-block max-w-full truncate align-middle px-2 py-1 rounded-full text-xs font-medium
<%= app.status === 'Einstellung' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' :
app.status === 'Absage' ? 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' :
app.status === 'Absage von meiner Seite' ? 'bg-rose-100 text-rose-800 dark:bg-rose-800 dark:text-rose-100' :
app.status === 'Vorstellungsgespräch' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-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 === '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 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' :
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>">
<%= app.status || '-' %>
</span>
</td>
<td class="px-6 pt-4 <%= padB %> whitespace-nowrap text-sm align-top">
</span>
<span class="app-actions flex items-center gap-3">
<%- include('partials/favorit-button', { j: app, endpoint: '/api/bewerbungen/' + app.id + '/favorit', compact: true }) %>
<a href="/bewerbung/<%= app.id %>"
class="inline-block align-middle text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 mr-3"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
aria-label="Bearbeiten">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
</a>
<button
class="delete-btn align-middle text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
data-id="<%= app.id %>">
<button type="button"
class="delete-btn text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
data-id="<%= app.id %>" aria-label="Löschen">
<svg class="w-5 h-5" 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>
</button>
</td>
</tr>
<% if (hatDetails) { %>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td colspan="6" class="px-6 pb-5 pt-0 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>
</span>
</div>
</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>
<% } %>
</td>
</tr>
<% } %>
</tbody>
</section>
<% }); %>
<% }); %>
<tbody id="searchNoResults" class="bg-white dark:bg-gray-800" style="display:none">
<tr>
<td colspan="6" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<p>Keine Firma gefunden für „<span id="searchNoResultsTerm"></span>".</p>
</td>
</tr>
</tbody>
</div>
<% } else { %>
<tbody class="bg-white dark:bg-gray-800">
<tr>
<td colspan="6" class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
<p>Keine Bewerbungen gefunden.</p>
<p class="mt-2 text-sm">Klicken Sie auf "Bewerbung hinzufügen", um Ihre erste Bewerbung einzutragen.</p>
</td>
</tr>
</tbody>
</div>
<% } %>
</table>
</div>
</div>
<!-- Letzte Aktivitäten: die jüngsten Statusänderungen quer über alle
Bewerbungen — der schnellste Blick darauf, was zuletzt passiert ist,
ohne jede Gruppe aufklappen zu müssen. -->
<section id="aktivitaetCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md mt-8">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Letzte Aktivitäten</h2>
<a href="/aktivitaeten" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">Alle</a>
</div>
<% if (aktivitaet && aktivitaet.length) { %>
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
<% aktivitaet.forEach(function (a) {
// E-Mail-Einträge bekommen einen eigenen Farbpunkt; bei Status-
// änderungen greift die Status-Farbpalette.
var dotCls = a.typ === 'email'
? (a.direction === 'out' ? 'bg-sky-500' : 'bg-blue-500')
: (statusDot[a.status] || 'bg-gray-300');
// Kopzeile: verknüpfte Bewerbung -> Firma·Stelle, sonst bei E-Mail
// der Absender/Empfänger, sonst Hinweis auf gelöschte Bewerbung.
var titel = '', titelSub = '';
if (a.bewerbung_id) {
titel = a.firma || '(ohne Firma)';
titelSub = a.stelle || '';
} else if (a.typ === 'email') {
titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail');
titelSub = '';
} else {
titel = 'Gelöschte Bewerbung';
}
%>
<li>
<!-- Eintrag zu einer noch existierenden Bewerbung ist ein Link, einer zu
einer gelöschten oder einer nicht zugeordneten E-Mail bleibt ein Block.
Bewusst zwei ausgeschriebene Zweige: den Tag-Namen samt href per <%%= %>
zusammenzusetzen escaped die Anführungszeichen und erzeugt Links wie
/%22/bewerbung/121%22. -->
<% if (a.bewerbung_id) { %>
<a href="/bewerbung/<%= a.bewerbung_id %>" class="flex items-start gap-3 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<% } else { %>
<div class="flex items-start gap-3 px-6 py-3">
<% } %>
<span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= dotCls %>"></span>
<span class="min-w-0 flex-1">
<span class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span class="text-sm font-medium <%= a.bewerbung_id ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400' %> break-words"><%= titel %></span>
<% if (titelSub) { %>
<span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= titelSub %></span>
<% } %>
</span>
<!-- Status, Datum und Kommentar in einer Zeile: ein rechtsbündiges
Datum würde auf dem Handy in eine eigene Zeile umbrechen und
dort ohne Bezug stehen. -->
<span class="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200"><%= a.status %></span>
<% if (a.datum) { %>
<span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span>
<% } %>
<% if (a.kommentar && a.kommentar.trim()) { %>
<span class="text-gray-500 dark:text-gray-400 break-words">· <%= a.kommentar %></span>
<% } %>
</span>
</span>
<% if (a.bewerbung_id) { %></a><% } else { %></div><% } %>
</li>
<% }); %>
</ul>
<% } else { %>
<div class="px-6 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.
</div>
<% } %>
</section>
<script>
// Live company search — filters the applications table by firma/stelle.
(function () {
function qsa(sel, ctx) {
return Array.prototype.slice.call((ctx || document).querySelectorAll(sel));
}
// Öffnet/schließt einen klappbaren Bereich und dreht sein Chevron mit.
function setOpen(collapsible, chevron, open) {
if (!collapsible) return;
collapsible.classList.toggle('open', open);
if (chevron) chevron.classList.toggle('rotate', open);
}
// Status-Gruppen (Gesendet, Eingangsbestätigung …) auf-/zuklappen.
qsa('.group-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
var body = btn.parentElement.querySelector('.group-body');
var chev = btn.querySelector('.chevron');
setOpen(body, chev, !body.classList.contains('open'));
});
});
// Klick auf eine Bewerbungs-Zeile öffnet die ganze Bewerbung
// (/bewerbung/:id). Klicks auf die Aktions-Buttons (Merken,
// Bearbeiten, Löschen) werden nicht abgefangen.
qsa('.app-line[data-bewerbung]').forEach(function (line) {
line.addEventListener('click', function (e) {
if (e.target.closest('.app-actions')) return;
window.location.href = '/bewerbung/' + line.getAttribute('data-bewerbung');
});
});
// ---- Volltextsuche (/api/suche): Bewerbungen + E-Mail-Korrespondenz ----
// Die Liste unten bleibt der Normalzustand; sobald gesucht wird, tritt an
// ihre Stelle die gerankte Trefferliste mit Fundstellen.
var input = document.getElementById('firmaSearch');
if (!input) return;
var clear = document.getElementById('firmaSearchClear');
var appRows = Array.prototype.slice.call(document.querySelectorAll('tbody.app-row'));
var groupHeaders = Array.prototype.slice.call(document.querySelectorAll('tbody.status-group'));
var noResults = document.getElementById('searchNoResults');
var noResultsTerm = document.getElementById('searchNoResultsTerm');
var hint = document.getElementById('firmaSearchHint');
var spinner = document.getElementById('firmaSearchSpinner');
var panel = document.getElementById('searchPanel');
var results = document.getElementById('searchResults');
var summary = document.getElementById('searchSummary');
var empty = document.getElementById('searchEmpty');
var emptyTerm = document.getElementById('searchEmptyTerm');
var fehler = document.getElementById('searchError');
var listCard = document.getElementById('listCard');
var termineCard = document.getElementById('termineCard');
var bewerbungFavoritenCard = document.getElementById('bewerbungFavoritenCard');
var aktivitaetCard = document.getElementById('aktivitaetCard');
function filter() {
var q = input.value.trim().toLowerCase();
if (clear) clear.classList.toggle('hidden', !q);
var MIN = 2; // kürzere Eingaben liefern nur Rauschen
var MARK_START = '\u0002'; // Fundstellen-Marker aus lib/suche.js
var MARK_END = '\u0003';
var timer = null;
var laufend = null; // AbortController der offenen Anfrage
var aktiv = -1; // per Tastatur ausgewählter Treffer
appRows.forEach(function (tb) {
var match = !q || (tb.dataset.search || '').indexOf(q) !== -1;
tb.style.display = match ? '' : 'none';
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// Snippet -> HTML: erst escapen (Mailtext ist Fremdinhalt), dann die
// Marker durch <mark> ersetzen. Andersherum wäre es eine XSS-Lücke.
function snippetHtml(s) {
return escapeHtml(s)
.split(escapeHtml(MARK_START)).join('<mark class="bg-yellow-200 dark:bg-yellow-500/40 dark:text-white rounded-sm px-0.5">')
.split(escapeHtml(MARK_END)).join('</mark>');
}
function datumDe(iso) {
if (!iso) return '';
var d = new Date(iso);
return isNaN(d) ? '' : d.toLocaleDateString('de-DE');
}
// "Frau Müller <m.mueller@bosch.de>" -> "Frau Müller"
function absender(v) {
var s = String(v || '').trim();
var m = s.match(/^\s*"?([^"<]+?)"?\s*<[^>]+>\s*$/);
return (m ? m[1].trim() : s) || '';
}
// Woher der Treffer stammt — die Herkunft ist bei einer Mailsuche die
// eigentliche Information ("steht in der Mail von Frau Müller").
function herkunft(f) {
if (f.quelle !== 'email' || !f.email) return f.feld_label || 'Bewerbung';
var von = absender(f.email.richtung === 'out' ? f.email.an : f.email.von);
var d = datumDe(f.email.datum);
return 'E-Mail' + (von ? (f.email.richtung === 'out' ? ' an ' : ' von ') + von : '') + (d ? ', ' + d : '');
}
function trefferZeile(t, i) {
var b = t.bewerbung || {};
var li = document.createElement('li');
li.setAttribute('role', 'option');
li.dataset.index = String(i);
var a = document.createElement('a');
a.href = t.typ === 'email' && !t.bewerbung_id ? '/postfach' : '/bewerbung/' + t.bewerbung_id;
a.className = 'block px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700/50';
var kopf = document.createElement('div');
kopf.className = 'flex flex-wrap items-baseline gap-x-2 gap-y-1';
var titel = document.createElement('span');
titel.className = 'font-medium text-gray-900 dark:text-white';
titel.textContent = t.bewerbung_id ? (b.firma || '(ohne Firma)') : 'E-Mail im Postfach';
kopf.appendChild(titel);
if (b.stelle) {
var stelle = document.createElement('span');
stelle.className = 'text-sm text-gray-600 dark:text-gray-300';
stelle.textContent = '· ' + b.stelle;
kopf.appendChild(stelle);
}
if (b.status) {
var st = document.createElement('span');
st.className = 'px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200';
st.textContent = b.status;
kopf.appendChild(st);
}
if (b.datum) {
var dt = document.createElement('span');
dt.className = 'ml-auto text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap';
dt.textContent = datumDe(b.datum);
kopf.appendChild(dt);
}
a.appendChild(kopf);
(t.fundstellen || []).forEach(function (f) {
var zeile = document.createElement('p');
zeile.className = 'mt-1.5 text-sm text-gray-600 dark:text-gray-300 flex flex-wrap items-baseline gap-x-2';
var wo = document.createElement('span');
wo.className = 'shrink-0 text-xs uppercase tracking-wide ' +
(f.quelle === 'email' ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500');
wo.textContent = herkunft(f);
var text = document.createElement('span');
text.className = 'min-w-0 break-words';
text.innerHTML = snippetHtml(f.snippet); // vorher escaped, siehe snippetHtml
zeile.appendChild(wo);
zeile.appendChild(text);
a.appendChild(zeile);
});
// Refresh each status group: hide headers with no visible rows, update counts.
groupHeaders.forEach(function (gh) {
var visible = 0, node = gh.nextElementSibling;
while (node && !node.classList.contains('status-group')) {
if (node.classList.contains('app-row') && node.style.display !== 'none') visible++;
node = node.nextElementSibling;
li.appendChild(a);
return li;
}
gh.style.display = (visible === 0 && q) ? 'none' : '';
var countEl = gh.querySelector('[data-group-count]');
if (countEl) countEl.textContent = visible;
function markiere(i) {
var zeilen = qsa('li', results);
if (!zeilen.length) return;
aktiv = (i + zeilen.length) % zeilen.length;
zeilen.forEach(function (li, n) {
li.classList.toggle('bg-blue-50', n === aktiv);
li.classList.toggle('dark:bg-gray-700/60', n === aktiv);
li.setAttribute('aria-selected', n === aktiv ? 'true' : 'false');
});
zeilen[aktiv].scrollIntoView({ block: 'nearest' });
}
var anyVisible = appRows.some(function (tb) { return tb.style.display !== 'none'; });
if (noResults) {
noResults.style.display = (!anyVisible && q) ? '' : 'none';
if (noResultsTerm) noResultsTerm.textContent = input.value.trim();
function zeigeListe() {
panel.classList.add('hidden');
if (listCard) listCard.classList.remove('hidden');
if (termineCard) termineCard.classList.remove('hidden');
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.remove('hidden');
if (aktivitaetCard) aktivitaetCard.classList.remove('hidden');
input.setAttribute('aria-expanded', 'false');
aktiv = -1;
}
function zeigeTreffer(daten) {
var treffer = daten.treffer || [];
results.innerHTML = '';
treffer.forEach(function (t, i) { results.appendChild(trefferZeile(t, i)); });
summary.textContent = treffer.length
? treffer.length + (treffer.length === 1 ? ' Treffer' : ' Treffer') + ' für „' + daten.q.trim() + '"'
: 'Keine Treffer';
empty.classList.toggle('hidden', treffer.length > 0);
if (emptyTerm) emptyTerm.textContent = daten.q.trim();
fehler.classList.add('hidden');
panel.classList.remove('hidden');
if (listCard) listCard.classList.add('hidden');
if (termineCard) termineCard.classList.add('hidden');
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.add('hidden');
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
input.setAttribute('aria-expanded', 'true');
aktiv = -1;
}
function zeigeFehler(msg) {
results.innerHTML = '';
summary.textContent = '';
empty.classList.add('hidden');
fehler.textContent = msg;
fehler.classList.remove('hidden');
panel.classList.remove('hidden');
if (listCard) listCard.classList.add('hidden');
if (termineCard) termineCard.classList.add('hidden');
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.add('hidden');
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
}
async function suchen(q) {
if (laufend) laufend.abort(); // veraltete Antwort darf die neue nicht überholen
laufend = new AbortController();
spinner.classList.remove('hidden');
try {
var res = await fetch('/api/suche?q=' + encodeURIComponent(q), {
headers: { Accept: 'application/json' }, signal: laufend.signal,
});
if (!res.ok) throw new Error('Die Suche ist fehlgeschlagen (' + res.status + ').');
zeigeTreffer(await res.json());
} catch (e) {
if (e.name === 'AbortError') return; // durch den nächsten Tastendruck ersetzt
zeigeFehler(e.message || 'Die Suche ist fehlgeschlagen.');
} finally {
spinner.classList.add('hidden');
}
}
input.addEventListener('input', filter);
if (clear) clear.addEventListener('click', function () {
function getippt() {
var q = input.value.trim();
clear.classList.toggle('hidden', !q);
if (hint) hint.classList.toggle('hidden', !!q);
clearTimeout(timer);
if (laufend) { laufend.abort(); laufend = null; }
spinner.classList.add('hidden');
if (q.length < MIN) { zeigeListe(); return; }
// Entprellt: erst tippen lassen, dann eine Anfrage stellen.
timer = setTimeout(function () { suchen(q); }, 180);
}
input.addEventListener('input', getippt);
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
input.value = '';
filter();
getippt();
input.blur();
return;
}
if (panel.classList.contains('hidden')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); markiere(aktiv + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); markiere(aktiv - 1); }
else if (e.key === 'Enter') {
var zeilen = qsa('li', results);
var ziel = zeilen[aktiv >= 0 ? aktiv : 0];
var link = ziel && ziel.querySelector('a');
if (link) { e.preventDefault(); link.click(); }
}
});
clear.addEventListener('click', function () {
input.value = '';
getippt();
input.focus();
});
// "/" springt ins Suchfeld — solange man nicht ohnehin schon tippt.
document.addEventListener('keydown', function (e) {
if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
e.preventDefault();
input.focus();
input.select();
});
})();
</script>
</div><!-- /dashboardWrap -->
</main>
<%- include('partials/footer') %>
<!-- Settings Modal -->
<div id="settingsModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold text-gray-800 dark:text-white">Benutzereinstellungen</h2>
<button id="closeSettingsModal" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="settingsForm" class="space-y-4">
<div>
<label for="userName" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Vollständiger Name *
</label>
<input
type="text"
id="userName"
name="name"
required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="Max Mustermann"
value="<%= settings.name || '' %>">
</div>
<div>
<label for="userAddress" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Adresse *
</label>
<textarea
id="userAddress"
name="adresse"
required
rows="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="Musterstraße 1, 12345 Musterstadt"><%= settings.adresse || '' %></textarea>
</div>
<div>
<label for="customerNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Jobcenter Kundennummer
</label>
<input
type="text"
id="customerNumber"
name="kundennummer"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="z.B. 123456789"
value="<%= settings.kundennummer || '' %>">
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
id="cancelSettings"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Abbrechen
</button>
<button
type="submit"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
Speichern
</button>
</div>
</form>
</div>
</div>
</div>
<%- include('partials/favorit-script') %>
<!-- Add/Edit Application Modal -->
<div id="applicationModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
@@ -548,6 +698,15 @@
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Labels
</label>
<div id="applicationLabels">
<%- include('partials/labelPicker', { labelOptions: labelOptions, selected: [] }) %>
</div>
</div>
<div>
<label for="applicationStatus" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Status (Anfangsstatus)
@@ -679,7 +838,7 @@
name="month"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
<option value="">-- Alle Monate --</option>
<% availableMonths.forEach(m => { %>
<% exportMonths.forEach(m => { %>
<option value="<%= m.year %>-<%= m.month %>"><%= m.month %> - <%= m.year %></option>
<% }); %>
</select>
@@ -694,7 +853,7 @@
name="year"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white">
<option value="">-- Alle Jahre --</option>
<% const pdfYears = [...new Set(availableMonths.map(m => m.year))].sort().reverse(); %>
<% const pdfYears = [...new Set(exportMonths.map(m => m.year))].sort().reverse(); %>
<% pdfYears.forEach(y => { %>
<option value="<%= y %>"><%= y %></option>
<% }); %>
+48 -35
View File
@@ -3,10 +3,10 @@
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<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="container mx-auto px-4 py-8 max-w-5xl">
<main class="flex-1 container mx-auto px-4 py-8 max-w-5xl">
<!-- Back link -->
<a href="/" 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">
@@ -15,26 +15,35 @@
Zurück zur Übersicht
</a>
<!-- Tabs: open offers vs. taken-over offers -->
<div class="flex flex-wrap items-center gap-2 mb-6">
<a href="/jobangebote" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm">
Offen (<%= jobangebote.length %>)
</a>
<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">
Übernommen (<%= uebernommenCount %>)
</a>
</div>
<!-- Tabs: open offers · favorites · taken-over offers -->
<% var favAnsicht = (typeof ansicht !== 'undefined' && ansicht === 'favoriten'); %>
<%- include('partials/jobangebote-tabs', {
aktiv: favAnsicht ? 'favoriten' : 'offen',
offenCount: offenCount,
favoritenCount: favoritenCount,
uebernommenCount: uebernommenCount,
}) %>
<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>
<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">
<% if (favAnsicht) { %>
Angebote, die du dir mit dem Stern gemerkt hast — unabhängig davon, ob sie
noch offen oder bereits übernommen sind. Ein erneuter Klick auf den Stern
entfernt das Angebot wieder aus dieser Liste. Die Merkliste gilt nur für
Jobangebote und ist von den gemerkten Bewerbungen getrennt.
<% } else { %>
Hier landen Stellen, die von Drittanbietersoftware über die
<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 — bereits übernommene findest du im Tab „Übernommen“.
Angebote, die du dir <em>nicht</em> gemerkt hast — bereits übernommene
findest du im Tab „Übernommen“, gemerkte im Tab „Gemerkt“.
<% } %>
</p>
</div>
<div class="shrink-0 flex items-center gap-2">
@@ -52,7 +61,9 @@
<% if (jobangebote && jobangebote.length) { %>
<ul class="mt-4 space-y-4">
<% 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="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1">
@@ -68,6 +79,9 @@
</div>
<p class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words"><%= j.firma %></p>
<p class="text-sm text-gray-700 dark:text-gray-200 break-words"><%= j.stelle %></p>
<% if (j.labelsArr && j.labelsArr.length) { %>
<div class="flex flex-wrap gap-1 mt-1.5"><%- include('partials/labelChips', { labels: j.labelsArr }) %></div>
<% } %>
<% if (j.ort || j.gehalt) { %>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
<% if (j.ort) { %><%= j.ort %><% } %>
@@ -113,7 +127,7 @@
<% } %>
<% } %>
<% if (j.quelle_url) { %>
<a href="<%= j.quelle_url %>" target="_blank" rel="noopener noreferrer"
<a href="<%= safeUrl(j.quelle_url) %>" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
<span class="truncate"><%= j.quelle_url %></span>
@@ -136,6 +150,7 @@
<!-- Actions -->
<div class="shrink-0 flex flex-col gap-2 self-start">
<%- include('partials/favorit-button', { j: j }) %>
<% if (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">
@@ -154,6 +169,7 @@
<% } %>
<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.');">
<% if (favAnsicht) { %><input type="hidden" name="zurueck" value="favoriten"><% } %>
<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">
<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>
@@ -170,6 +186,7 @@
Beschreibung / Details bearbeiten
</summary>
<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">
<label class="block text-xs">
<span class="text-gray-500 dark:text-gray-400">Firma</span>
@@ -212,6 +229,10 @@
<span class="text-gray-500 dark:text-gray-400">Stellenbeschreibung <span class="text-gray-400">(beliebig lang — wird bei „Übernehmen“ als Stellenbeschreibung für die KI mitgenommen)</span></span>
<textarea name="beschreibung" rows="10" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm leading-relaxed" placeholder="Vollständige Stellenbeschreibung hier einfügen …"><%= j.beschreibung || '' %></textarea>
</label>
<div class="text-xs">
<span class="text-gray-500 dark:text-gray-400">Labels</span>
<div class="mt-1"><%- include('partials/labelPicker', { labelOptions: labelOptions, selected: j.labelsArr }) %></div>
</div>
<button type="submit" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
<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="M5 13l4 4L19 7"></path></svg>
Speichern
@@ -221,6 +242,17 @@
</li>
<% }); %>
</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 { %>
<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">
@@ -273,25 +305,6 @@
<script>
(function () {
const root = document.documentElement;
const dm = localStorage.getItem('darkMode');
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
const toggle = document.getElementById('darkModeToggle');
const sun = document.getElementById('sunIcon');
const moon = document.getElementById('moonIcon');
function sync() {
const d = root.classList.contains('dark');
if (sun) sun.classList.toggle('hidden', d);
if (moon) moon.classList.toggle('hidden', !d);
}
sync();
if (toggle) toggle.addEventListener('click', () => {
root.classList.toggle('dark');
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
sync();
});
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
+16 -32
View File
@@ -3,10 +3,10 @@
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<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="container mx-auto px-4 py-8 max-w-5xl">
<main class="flex-1 container mx-auto px-4 py-8 max-w-5xl">
<!-- Back link -->
<a href="/jobangebote" 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">
@@ -15,15 +15,15 @@
Zurück zu den Jobangeboten
</a>
<!-- Tabs: open offers vs. taken-over offers -->
<div class="flex flex-wrap items-center gap-2 mb-6">
<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">
Offen (<%= offenCount %>)
</a>
<a href="/jobangebote/uebernommen" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm">
Übernommen (<%= uebernommen.length %>)
</a>
</div>
<!-- Tabs: open offers · favorites · taken-over offers -->
<%- include('partials/jobangebote-tabs', {
aktiv: 'uebernommen',
offenCount: offenCount,
favoritenCount: favoritenCount,
uebernommenCount: uebernommenCount,
}) %>
<%- include('partials/favorit-script') %>
<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">
@@ -43,7 +43,9 @@
<% if (uebernommen && uebernommen.length) { %>
<ul class="mt-4 space-y-4">
<% 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="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1">
@@ -77,7 +79,7 @@
</p>
<% } %>
<% if (j.quelle_url) { %>
<a href="<%= j.quelle_url %>" target="_blank" rel="noopener noreferrer"
<a href="<%= safeUrl(j.quelle_url) %>" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline mt-2 break-all">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg>
<span class="truncate"><%= j.quelle_url %></span>
@@ -100,6 +102,7 @@
<!-- Actions -->
<div class="shrink-0 flex flex-col gap-2 self-start">
<%- include('partials/favorit-button', { j: j }) %>
<% if (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">
@@ -136,25 +139,6 @@
<script>
(function () {
const root = document.documentElement;
const dm = localStorage.getItem('darkMode');
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
const toggle = document.getElementById('darkModeToggle');
const sun = document.getElementById('sunIcon');
const moon = document.getElementById('moonIcon');
function sync() {
const d = root.classList.contains('dark');
if (sun) sun.classList.toggle('hidden', d);
if (moon) moon.classList.toggle('hidden', !d);
}
sync();
if (toggle) toggle.addEventListener('click', () => {
root.classList.toggle('dark');
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
sync();
});
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
})();
+342
View File
@@ -0,0 +1,342 @@
<!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-4xl">
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-800 dark:text-white">Jobsuche</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Du legst fest, <strong>wo</strong> gesucht wird. <strong>Was</strong> gesucht wird Rollen und
Technologien leitet die KI aus deinem hinterlegten Lebenslauf ab.
</p>
</div>
<% if (fremd) { %>
<div class="mb-6 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4">
<p class="text-sm text-amber-800 dark:text-amber-200">
Du bearbeitest als Administrator das Suchprofil von <strong><%= zielUser.username %></strong>.
<a href="/jobsuche" class="underline">Zum eigenen Profil</a>
</p>
</div>
<% } %>
<% if (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">
<p class="text-sm text-red-800 dark:text-red-200"><%= fehler %></p>
</div>
<% } %>
<% if (hinweis) { %>
<div class="mb-6 rounded-lg border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/20 p-4">
<p class="text-sm text-green-800 dark:text-green-200"><%= hinweis %></p>
</div>
<% } %>
<% if (!hatLebenslauf || !hatOllamaKey) { %>
<div class="mb-6 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4">
<p class="text-sm font-medium text-amber-800 dark:text-amber-200 mb-1">Vor dem ersten Suchlauf fehlt noch etwas:</p>
<ul class="text-sm text-amber-800 dark:text-amber-200 list-disc list-inside space-y-0.5">
<% if (!hatLebenslauf) { %>
<li>Kein Lebenslauf hinterlegt ohne ihn kann die KI dein Suchprofil nicht ableiten.
<a href="/vorlagen" class="underline">Unter Vorlagen anlegen</a></li>
<% } %>
<% if (!hatOllamaKey) { %>
<li>Kein Ollama-API-Schlüssel der Suchlauf läuft über dein eigenes KI-Kontingent.
<a href="/einstellungen" class="underline">In den Einstellungen eintragen</a></li>
<% } %>
</ul>
</div>
<% } %>
<form action="/jobsuche" method="POST" class="space-y-6">
<% 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">
<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>
<div class="space-y-2 mb-5">
<% var modusText = {
regional: 'Nur Stellen mit Arbeitsort in den unten genannten Städten.',
remote: 'Nur Stellen, die zu 100 % im Homeoffice ausübbar sind deutschlandweit, Firmensitz egal.',
beides: 'Zwei Durchgänge: regional in deinen Städten und zusätzlich 100 % Remote deutschlandweit.'
}; %>
<% modi.forEach(function (m) { %>
<label class="flex items-start gap-3 p-3 rounded-md border border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40">
<input type="radio" name="modus" value="<%= m %>" <%= profil.modus === m ? 'checked' : '' %>
class="mt-0.5 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
<span>
<span class="block text-sm font-medium text-gray-800 dark:text-white">
<%= m === 'regional' ? 'Regional' : (m === 'remote' ? '100 % Remote' : 'Regional + Remote') %>
</span>
<span class="block text-xs text-gray-500 dark:text-gray-400"><%= modusText[m] %></span>
</span>
</label>
<% }); %>
</div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="staedte">Städte</label>
<textarea id="staedte" name="staedte" rows="3" spellcheck="false"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
placeholder="Berlin, Potsdam, Hamburg, München"><%= profil.staedte.join(', ') %></textarea>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
Kommagetrennt, höchstens <%= maxStaedte %>. Die <strong>erste Stadt gilt als Wohnort</strong> und wird
am höchsten priorisiert. Bei „100 % Remote“ werden die Städte ignoriert.
</p>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Feinschliff</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Unter welchen Titeln gesucht wird und was die Suche zusätzlich beachten oder verwerfen soll.
</p>
</div>
<button type="button" id="feinschliffKi"
<%= (!hatUnterlagen || !hatOllamaKey) ? 'disabled' : '' %>
title="<%= !hatUnterlagen ? 'Dafür fehlen Basis-Unterlagen unter Vorlagen.' : (!hatOllamaKey ? 'Dafür fehlt der Ollama-API-Schlüssel in den Einstellungen.' : 'Leitet Vorschläge aus deinen Basis-Unterlagen ab.') %>"
class="inline-flex items-center gap-2 px-3 py-1.5 shrink-0 text-sm border border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400 rounded-md transition-colors hover:bg-blue-50 dark:hover:bg-blue-900/30 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent">
<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="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>
</svg>
<span id="feinschliffKiText">Per KI vorschlagen</span>
</button>
</div>
<p id="feinschliffHinweis" class="hidden text-sm mt-3"></p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3 mb-5">
Der KI-Vorschlag wertet deine <a href="/vorlagen" class="underline">Basis-Unterlagen</a> aus und
überschreibt alle drei Felder. Gespeichert wird er erst, wenn du unten auf „Speichern“ klickst.
</p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="berufsbezeichnungen">Berufsbezeichnungen</label>
<textarea id="berufsbezeichnungen" name="berufsbezeichnungen" rows="2"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
placeholder="z. B. Systemadministrator, IT-Administrator, Fachinformatiker Systemintegration"><%= profil.berufsbezeichnungen %></textarea>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
<strong>Danach wird gesucht</strong> was hier fehlt, findet die Suche nicht. Kommagetrennt;
gängige Varianten desselben Titels ergänzt die Suche selbst. Leer = die Suche leitet die Titel
wie bisher aus deinem Lebenslauf ab.
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zusatz_begriffe">Zusätzlich berücksichtigen</label>
<textarea id="zusatz_begriffe" name="zusatz_begriffe" rows="3"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
placeholder="z. B. Teilzeit, Rechenzentrum, Systemhaus"><%= profil.zusatz_begriffe %></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="ausschluesse">Ausschließen</label>
<textarea id="ausschluesse" name="ausschluesse" rows="3"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
placeholder="z. B. Zeitarbeit, Headhunter, Schichtdienst"><%= profil.ausschluesse %></textarea>
</div>
</div>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Zeitplan</h3>
<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 id="zeitplan_felder" class="flex flex-wrap gap-2 mb-5 mt-5 <%= !profil.aktiv ? 'opacity-60' : '' %>">
<% 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 <%= 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 %>"
<%= profil.zeitplan_tage.indexOf(t.nr) !== -1 ? '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-gray-700 dark:text-gray-300"><%= t.kurz %></span>
</label>
<% }); %>
</div>
<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 %>"
<%= !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">
Der Lauf startet innerhalb weniger Minuten nach dieser Uhrzeit.
</p>
</section>
<div class="flex items-center justify-end gap-3">
<a href="/jobsuche<%= fremd ? '?user=' + zielUser.id : '' %>"
class="px-4 py-2 text-sm 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">
Verwerfen
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
<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="M5 13l4 4L19 7"></path>
</svg>
Speichern
</button>
</div>
</form>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mt-6">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Suchläufe</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Ein Lauf durchsucht das Web und legt neue Treffer als
<a href="/jobangebote" class="text-blue-600 dark:text-blue-400 hover:underline">Jobangebote</a> an.
</p>
</div>
<% if (darfSuchlaufStarten) { %>
<form action="/jobsuche/start" method="POST" class="shrink-0">
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Jetzt suchen
</button>
</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 id="laeufe">
<%- include('partials/suchlaeufe', { laeufe: laeufe }) %>
</div>
</section>
</main>
<%- include('partials/footer') %>
<script>
(function () {
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
// While a run is queued or in flight, refresh the list so the user sees it
// progress without reloading. Stops once nothing is pending any more.
const zielUser = <%- fremd ? zielUser.id : 'null' %>;
const url = '/jobsuche/laeufe' + (zielUser ? '?user=' + zielUser : '');
let timer = null;
function offen(laeufe) {
return laeufe.some(function (l) { return l.status === 'angefordert' || l.status === 'laeuft'; });
}
async function poll() {
try {
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const laeufe = await res.json();
if (!offen(laeufe)) {
clearInterval(timer);
location.reload(); // fertig -> Ergebnisse serverseitig rendern
}
} catch (e) { /* nächster Tick */ }
}
const initial = <%- JSON.stringify(laeufe.map(function (l) { return { status: l.status }; })) %>;
if (offen(initial)) timer = setInterval(poll, 5000);
})();
// "Per KI vorschlagen": lets the model derive the two guard-rail lists from the
// user's Basis-Unterlagen. Only fills the fields — saving stays a deliberate act.
(function () {
const btn = document.getElementById('feinschliffKi');
if (!btn) return;
const label = document.getElementById('feinschliffKiText');
const hinweis = document.getElementById('feinschliffHinweis');
const berufe = document.getElementById('berufsbezeichnungen');
const zusatz = document.getElementById('zusatz_begriffe');
const ausschluesse = document.getElementById('ausschluesse');
const zielUser = <%- fremd ? zielUser.id : 'null' %>;
const FARBE = {
info: 'text-gray-500 dark:text-gray-400',
ok: 'text-green-700 dark:text-green-400',
fehler: 'text-red-600 dark:text-red-400',
};
function melde(text, art) {
hinweis.textContent = text;
hinweis.className = 'text-sm mt-3 ' + FARBE[art];
}
btn.addEventListener('click', async function () {
btn.disabled = true;
label.textContent = 'KI denkt nach …';
melde('Die KI liest deine Basis-Unterlagen das dauert einen Moment.', 'info');
const modus = document.querySelector('input[name="modus"]:checked');
try {
const res = await fetch('/jobsuche/feinschliff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ziel_user: zielUser,
modus: modus ? modus.value : '',
staedte: document.getElementById('staedte').value,
}),
});
const data = await res.json().catch(function () { return {}; });
if (!res.ok) throw new Error(data.error || ('Fehler ' + res.status));
berufe.value = data.berufsbezeichnungen || '';
zusatz.value = data.zusatz_begriffe || '';
ausschluesse.value = data.ausschluesse || '';
melde('Vorschlag übernommen. Prüfe ihn und klicke auf „Speichern“.', 'ok');
} catch (e) {
melde(e.message || 'Der KI-Vorschlag ist fehlgeschlagen.', 'fehler');
} finally {
btn.disabled = false;
label.textContent = 'Per KI vorschlagen';
}
});
})();
// 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>
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="de" class="dark">
<head>
<%- include('partials/head') %>
<style>
body { background: linear-gradient(135deg, #1e3a8a 0%, #1f2937 100%); }
</style>
</head>
<body class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-sm">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl p-8">
<div class="flex flex-col items-center mb-6">
<svg class="w-12 h-12 text-blue-600 dark:text-blue-400 mb-2" 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>
<h1 class="text-xl font-bold text-gray-800 dark:text-white">NextJobs</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Bitte anmelden</p>
</div>
<% if (error) { %>
<div class="mb-4 rounded-md bg-red-50 dark:bg-red-900/30 px-4 py-2.5 text-sm text-red-700 dark:text-red-300 ring-1 ring-red-200 dark:ring-red-800">
<%= error %>
</div>
<% } %>
<form method="POST" action="/login" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="username">Benutzername</label>
<input id="username" name="username" type="text" required autofocus
value="<%= username %>"
class="w-full 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" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="password">Passwort</label>
<input id="password" name="password" type="password" required
class="w-full 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" />
</div>
<label class="flex items-center gap-2 cursor-pointer select-none">
<input id="dauerhaft" name="dauerhaft" type="checkbox" value="1" <%= dauerhaft ? '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">Angemeldet bleiben</span>
</label>
<button type="submit"
class="w-full rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium py-2.5">
Anmelden
</button>
</form>
</div>
</div>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<%
// Gemerkte Bewerbungen — Sektion auf der Startseite, direkt unter den
// anstehenden Terminen. Zeigt alle favorisierten Bewerbungen unabhängig von
// 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) { %>
<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">
<div class="flex flex-wrap items-center gap-2 px-5 py-3.5 border-b border-gray-100 dark:border-gray-700/60">
<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>
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-200">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">
<%= bewerbungFavoriten.length %>
</span>
</div>
<div class="flex flex-wrap gap-2 p-5">
<% bewerbungFavoriten.forEach(function(b){ %>
<a href="/bewerbung/<%= b.id %>"
title="<%= b.firma %>"
class="gemerkt-chip inline-flex items-center max-w-[18rem] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700/70 bg-gray-50 dark:bg-gray-700/30 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-white dark:hover:bg-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-sm transition-all">
<span class="truncate"><%= b.firma %></span>
</a>
<% }); %>
</div>
</section>
<% } %>
+646
View File
@@ -0,0 +1,646 @@
<%
// Einrichtungsassistent für Nutzer, die noch keine Bewerbung angelegt haben.
// Server liefert `setupWizard` mit: aktiv, persoenlich (row),
// anschreiben/lebenslauf ({id,inhalt}), hasApiKey, ollamaModel, ollamaHost,
// cfg (nicht-geheime Mail-/CalDAV-Werte + hasMailPassword + hasApiToken).
// Das Partial rendert nur den Wizard; das umgebende index.ejs blendet
// gleichzeitig das Dashboard aus (Markup bleibt im DOM für main.js).
var p = setupWizard.persoenlich || {};
var anschreibenInhalt = setupWizard.anschreiben ? setupWizard.anschreiben.inhalt : '';
var lebenslaufInhalt = setupWizard.lebenslauf ? setupWizard.lebenslauf.inhalt : '';
var c = setupWizard.cfg || {};
%>
<style>
/* Fortschrittsanzeige: Verbindungslinie zwischen den Schritt-Punkten. */
.einr-steps { position: relative; }
.einr-steps::before {
content: "";
position: absolute;
top: 14px; /* Mitte der 28px-Punkte */
left: 6%;
right: 6%;
height: 2px;
background-color: rgba(255, 255, 255, 0.25);
}
.einr-step-dot {
position: relative;
z-index: 1;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.einr-step.is-active .einr-step-dot { transform: scale(1.12); }
/* Sanftes Einblenden der Schrittinhalte. */
.einr-panel { animation: einrFade 0.28s ease; }
@keyframes einrFade {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) { .einr-panel { animation: none; } }
</style>
<section id="einrichtung" class="mb-10">
<div class="mx-auto max-w-3xl overflow-hidden rounded-2xl border border-gray-200/80 bg-white shadow-xl shadow-gray-900/5 dark:border-white/10 dark:bg-gray-800">
<!-- Kopfbereich mit Verlaufsanzeige -->
<div class="relative bg-gradient-to-br from-sky-500 via-blue-600 to-indigo-600 px-5 py-7 text-white sm:px-8">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-3">
<span class="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-white/15 ring-1 ring-white/25 backdrop-blur">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496 3.06c.318.392.74.665 1.215.82a3.51 3.51 0 003.99-1.51l1.75-2.93a1.5 1.5 0 00-2.57-1.55l-1.75 2.93a.75.75 0 01-1.05.23l-.4-.27m-3.24-2.29l-2.83-3.5m0 0A3.291 3.291 0 001.87 7.28l.8 1.2m4.4 3.69l-3.6-5.4m1.55 6.6L4 7.6"/>
</svg>
</span>
<div>
<h1 class="text-xl font-semibold leading-tight sm:text-2xl">NextJobs einrichten</h1>
<p class="mt-1 max-w-md text-sm text-white/80">
In wenigen Schritten bist du startklar für deine erste Bewerbung.
Jeder Schritt lässt sich überspringen und später nachholen.
</p>
</div>
</div>
<button type="button" id="einrAusblenden"
class="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-white/80 ring-1 ring-white/25 transition hover:bg-white/10 hover:text-white">
Ausblenden
</button>
</div>
<!-- Schrittanzeige (7 Schritte; Labels nur auf >= sm) -->
<ol id="einrSteps" class="einr-steps mt-7 flex items-start justify-between gap-1">
<% var stepLabels = ['Willkommen','Profil','Vorlagen','KI','E-Mail','API','Abschluss']; %>
<% for (var si = 1; si <= 7; si++) { %>
<li class="einr-step flex flex-1 flex-col items-center gap-1.5 text-center" data-step="<%= si %>">
<span class="einr-step-dot grid h-7 w-7 place-items-center rounded-full bg-white/20 text-xs font-semibold ring-1 ring-white/30"><%= si %></span>
<span class="hidden text-[11px] font-medium text-white/80 sm:block"><%= stepLabels[si-1] %></span>
</li>
<% } %>
</ol>
</div>
<!-- Formular / Schrittinhalte -->
<form id="einrForm" class="px-5 py-6 sm:px-8" autocomplete="off" novalidate>
<!-- Schritt 1: Willkommen -->
<div class="einr-panel" data-panel="1">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Herzlich willkommen!</h2>
<p class="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
NextJobs begleitet dich vom Stellenimport bis zur fertigen Bewerbung — mit
automatisch zugeschnittenen Anschreiben und Lebenslauf, E-Mail-Posteingang
und Bewerbungskalender. Damit das klappt, hinterlegen wir zuerst die nötigen
Daten. Das dauert etwa fünf Minuten.
</p>
<ul class="mt-5 space-y-2.5 text-sm text-gray-600 dark:text-gray-300">
<li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">Persönliche Angaben</strong> — fließen in jede Bewerbung ein.</span></li>
<li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">Basis-Unterlagen</strong> — dein Standard-Anschreiben &amp; Lebenslauf als Text.</span></li>
<li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">Ollama-KI</strong> — erzeugt daraus die passenden Unterlagen.</span></li>
<li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">E-Mail &amp; Kalender</strong> — Posteingang versenden und Termine synchronisieren.</span></li>
<li class="flex items-start gap-2.5"><svg class="mt-0.5 h-5 w-5 shrink-0 text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg><span><strong class="font-medium text-gray-800 dark:text-white">REST-API</strong> — optional, für Drittanbietersoftware.</span></li>
</ul>
</div>
<!-- Schritt 2: Persönliche Angaben -->
<div class="einr-panel hidden" data-panel="2">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Persönliche Angaben</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Diese Daten stehen oben in jedem Anschreiben und Lebenslauf. Pflichtfeld ist nur der Name.
</p>
<div class="mt-5 space-y-4">
<div>
<label for="einr-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Vollständiger Name <span class="text-red-500">*</span></label>
<input type="text" id="einr-name" name="name" required
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"
placeholder="Max Mustermann" value="<%= p.name || '' %>">
</div>
<div>
<label for="einr-adresse" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Adresse</label>
<textarea id="einr-adresse" name="adresse" rows="2"
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"
placeholder="Musterstraße 1, 12345 Musterstadt"><%= p.adresse || '' %></textarea>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="einr-email" class="block text-xs font-medium text-gray-500 dark:text-gray-400">E-Mail</label>
<input type="email" id="einr-email" name="email"
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"
placeholder="name@example.com" value="<%= p.email || '' %>">
</div>
<div>
<label for="einr-telefon" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Telefon</label>
<input type="text" id="einr-telefon" name="telefon"
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"
placeholder="z. B. 030 12345678" value="<%= p.telefon || '' %>">
</div>
<div>
<label for="einr-ort" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Wohnort</label>
<input type="text" id="einr-ort" name="ort"
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"
placeholder="z. B. Berlin" value="<%= p.ort || '' %>">
</div>
<div>
<label for="einr-kundennummer" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Jobcenter-Kundennummer</label>
<input type="text" id="einr-kundennummer" name="kundennummer"
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"
placeholder="z. B. 123456789" value="<%= p.kundennummer || '' %>">
</div>
<div>
<label for="einr-webseite" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Webseite</label>
<input type="text" id="einr-webseite" name="webseite"
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"
placeholder="z. B. nextjobs.cc" value="<%= p.webseite || '' %>">
</div>
<div>
<label for="einr-geburtsdatum" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Geburtsdatum</label>
<input type="text" id="einr-geburtsdatum" name="geburtsdatum"
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"
placeholder="TT.MM.JJJJ" value="<%= p.geburtsdatum || '' %>">
</div>
</div>
</div>
</div>
<!-- Schritt 3: Basis-Unterlagen -->
<div class="einr-panel hidden" data-panel="3">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Basis-Unterlagen</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Füge dein Standard-Anschreiben und deinen Lebenslauf als Text ein. Die KI
nutzt sie als Faktengrundlage und erfindet nichts hinzu.
</p>
<div class="mt-5 space-y-5">
<div>
<label for="einr-anschreiben" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Standard-Anschreiben</label>
<textarea id="einr-anschreiben" rows="6"
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm leading-relaxed text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Sehr geehrte Damen und Herren, …"><%= anschreibenInhalt %></textarea>
</div>
<div>
<label for="einr-lebenslauf" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Lebenslauf</label>
<textarea id="einr-lebenslauf" rows="8"
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm leading-relaxed text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Persönliche Daten, Berufserfahrung, Ausbildung, Kenntnisse …"><%= lebenslaufInhalt %></textarea>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500">
Tipp: Ausführliche Vorlagen lassen sich später unter „Vorlagen“ verfeinern — inkl. Design und Foto.
</p>
</div>
</div>
<!-- Schritt 4: Ollama-KI -->
<div class="einr-panel hidden" data-panel="4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Ollama-KI einrichten</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Über die Ollama Cloud erzeugt NextJobs automatisch zugeschnittene
Bewerbungsunterlagen. Ohne API-Schlüssel bleibt die KI deaktiviert —
später unter „Einstellungen“ nachholbar.
</p>
<div class="mt-5 space-y-4">
<div>
<label for="einr-apikey" class="block text-sm font-medium text-gray-700 dark:text-gray-300">API-Schlüssel</label>
<div class="relative mt-1">
<input type="password" id="einr-apikey" name="OLLAMA_API_KEY"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="ol-…" autocomplete="off">
<button type="button" class="einr-reveal absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" data-target="einr-apikey" title="Wert anzeigen/verbergen" tabindex="-1">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
</div>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
Schlüssel holen unter
<a href="https://ollama.com/settings/keys" target="_blank" rel="noopener" class="text-blue-600 hover:underline dark:text-sky-400">ollama.com/settings/keys</a>.
<% if (setupWizard.hasApiKey) { %><span class="ml-1 text-green-600 dark:text-green-400">· bereits hinterlegt</span><% } %>
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<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"
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 || 'glm-5.2:cloud' %>">
</div>
<div>
<label for="einr-host" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Host</label>
<input type="text" id="einr-host" name="OLLAMA_HOST"
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.ollamaHost || 'https://ollama.com' %>">
</div>
</div>
</div>
</div>
<!-- Schritt 5: E-Mail & Kalender -->
<div class="einr-panel hidden" data-panel="5">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">E-Mail &amp; Kalender</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
E-Mail-Empfang (IMAP) und -Versand (SMTP) laufen über dein eigenes Postfach.
Der Bewerbungskalender nutzt CalDAV und teilt sich Login &amp; Passwort mit
der E-Mail. Beides ist optional — ohne Angabe bleiben Postfach und
Kalender-Sync deaktiviert.
</p>
<!-- E-Mail -->
<div class="mt-5">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">E-Mail (SMTP / IMAP)</h3>
<div class="mt-3 grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="einr-mail-host" class="block text-xs font-medium text-gray-500 dark:text-gray-400">SMTP/IMAP Host</label>
<input type="text" id="einr-mail-host" 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"
placeholder="mail.example.com" value="<%= c.MAIL_HOST || '' %>">
</div>
<div>
<label for="einr-mail-smtp-port" class="block text-xs font-medium text-gray-500 dark:text-gray-400">SMTP-Port</label>
<input type="text" id="einr-mail-smtp-port" 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="<%= c.MAIL_SMTP_PORT || '587' %>">
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">587 = STARTTLS, 465 = implicit TLS</p>
</div>
<div>
<label for="einr-mail-imap-port" class="block text-xs font-medium text-gray-500 dark:text-gray-400">IMAP-Port</label>
<input type="text" id="einr-mail-imap-port" 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="<%= c.MAIL_IMAP_PORT || '993' %>">
</div>
<div>
<label for="einr-mail-user" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Benutzername (Login)</label>
<input type="text" id="einr-mail-user" 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"
placeholder="name@example.com" value="<%= c.MAIL_USER || '' %>">
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">Auch CalDAV-Login</p>
</div>
<div>
<label for="einr-mail-password" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Passwort</label>
<div class="relative mt-1">
<input type="password" id="einr-mail-password"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="<%= c.hasMailPassword ? 'vorhanden — leer lassen, um nichts zu ändern' : 'Passwort' %>" autocomplete="off">
<button type="button" class="einr-reveal absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" data-target="einr-mail-password" title="Wert anzeigen/verbergen" tabindex="-1">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
</div>
</div>
<div>
<label for="einr-mail-fromname" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Absendername</label>
<input type="text" id="einr-mail-fromname" 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"
placeholder="Max Mustermann" value="<%= c.MAIL_FROM_NAME || '' %>">
</div>
<div>
<label for="einr-mail-from" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Absenderadresse</label>
<input type="text" id="einr-mail-from" 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"
placeholder="leer = Benutzername" value="<%= c.MAIL_FROM || '' %>">
</div>
<div>
<label for="einr-mail-mailbox" class="block text-xs font-medium text-gray-500 dark:text-gray-400">IMAP-Postfach</label>
<input type="text" id="einr-mail-mailbox" 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="<%= c.MAIL_IMAP_MAILBOX || 'INBOX' %>">
</div>
<div>
<label for="einr-mail-poll" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Abrufintervall (ms)</label>
<input type="text" id="einr-mail-poll" 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="<%= c.MAIL_POLL_MS || '180000' %>">
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">greift nach Neustart</p>
</div>
</div>
</div>
<!-- CalDAV -->
<div class="mt-7">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Bewerbungskalender (CalDAV)</h3>
<div class="mt-3 grid gap-4 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="einr-caldav-url" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Kalender-URL</label>
<input type="text" id="einr-caldav-url" 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"
placeholder="https://mail.example.com/SOGo/dav/name@…/Calendar/XXXX/" value="<%= c.CALDAV_URL || '' %>">
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">Voll-URL der Sammlung, mit abschließendem /. Anmeldung über Benutzername/Passwort oben.</p>
</div>
<div>
<label for="einr-caldav-alarm" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Erinnerung (Min. vor Termin)</label>
<input type="text" id="einr-caldav-alarm" 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="<%= c.CALDAV_ALARM_MIN || '60' %>">
</div>
<div>
<label for="einr-caldav-poll" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Sync-Intervall (ms)</label>
<input type="text" id="einr-caldav-poll" 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="<%= c.CALDAV_POLL_MS || '300000' %>">
<p class="mt-1 text-[11px] text-gray-400 dark:text-gray-500">greift nach Neustart</p>
</div>
</div>
</div>
</div>
<!-- Schritt 6: REST-API -->
<div class="einr-panel hidden" data-panel="6">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">REST-API (optional)</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Ist ein Token gesetzt, ist <code class="rounded bg-gray-100 px-1 dark:bg-gray-700">/api/v1</code>
für dich aktiv und erwartet den Wert im Header
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700">X-API-Key</code>.
Der Token identifiziert dein Konto — Anfragen sehen und ändern nur deine
eigenen Daten. Swagger unter <a href="/swagger" class="text-blue-600 hover:underline dark:text-sky-400">/swagger</a>.
</p>
<div class="mt-5">
<label for="einr-api-token" class="block text-sm font-medium text-gray-700 dark:text-gray-300">API-Token</label>
<div class="relative mt-1">
<input type="password" id="einr-api-token"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 pr-10 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="<%= c.hasApiToken ? 'vorhanden — leer lassen, um nichts zu ändern' : 'Token erzeugen oder eintragen' %>" autocomplete="off">
<button type="button" class="einr-reveal absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" data-target="einr-api-token" title="Wert anzeigen/verbergen" tabindex="-1">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2">
<button type="button" id="einr-token-gen"
class="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 transition hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
Neu generieren
</button>
<button type="button" id="einr-token-copy"
class="inline-flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 transition hover:bg-gray-100 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
Kopieren
</button>
<span id="einr-token-hint" class="hidden text-xs text-amber-600 dark:text-amber-400"></span>
<% if (c.hasApiToken) { %><span class="text-xs text-green-600 dark:text-green-400">· bereits hinterlegt</span><% } %>
</div>
<p class="mt-2 text-[11px] text-gray-400 dark:text-gray-500">Leer lassen = vorhandenen Token nicht anfassen. Ein neuer Token macht den alten sofort ungültig.</p>
</div>
</div>
<!-- Schritt 7: Abschluss -->
<div class="einr-panel hidden" data-panel="7">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Fast geschafft!</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Hier ist dein Stand. Fehlende Punkte lassen sich jederzeit unter
„Vorlagen“ oder „Einstellungen“ nachholen.
</p>
<ul class="mt-5 divide-y divide-gray-100 dark:divide-gray-700/60 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<li class="flex items-center gap-3 px-4 py-3" data-check="profil"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Persönliche Angaben</span></li>
<li class="flex items-center gap-3 px-4 py-3" data-check="anschreiben"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Standard-Anschreiben hinterlegt</span></li>
<li class="flex items-center gap-3 px-4 py-3" data-check="lebenslauf"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Lebenslauf hinterlegt</span></li>
<li class="flex items-center gap-3 px-4 py-3" data-check="ki"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Ollama-KI aktiv</span></li>
</ul>
<p class="mt-5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">Weitere Verbindungen (optional)</p>
<ul class="mt-2 divide-y divide-gray-100 dark:divide-gray-700/60 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<li class="flex items-center gap-3 px-4 py-3" data-opt="mail"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">E-Mail (SMTP/IMAP)</span></li>
<li class="flex items-center gap-3 px-4 py-3" data-opt="kalender"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">Bewerbungskalender (CalDAV)</span></li>
<li class="flex items-center gap-3 px-4 py-3" data-opt="api"><span class="einr-ico grid h-6 w-6 place-items-center rounded-full"></span><span class="text-sm text-gray-700 dark:text-gray-200">REST-API-Token</span></li>
</ul>
<div class="mt-5 rounded-lg bg-blue-50 p-4 text-sm text-blue-800 dark:bg-blue-500/10 dark:text-blue-200">
<p class="font-medium">Empfehlung: automatische Stellensuche einrichten</p>
<p class="mt-1 text-blue-700/90 dark:text-blue-200/80">
Lege unter „Stellensuche → Suchprofil &amp; Läufe“ fest, wo und wonach
automatisch gesucht wird. So landen passende Stellenangebote direkt hier.
</p>
<div class="mt-3 flex flex-wrap gap-2">
<a href="/jobsuche" class="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-blue-700">Suchprofil öffnen</a>
<a href="/vorlagen" class="inline-flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-xs font-medium text-blue-600 ring-1 ring-blue-200 transition hover:bg-blue-50 dark:bg-transparent dark:text-blue-200 dark:ring-blue-400/40 dark:hover:bg-white/5">Vorlagen verfeinern</a>
<a href="/einstellungen" class="inline-flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-xs font-medium text-blue-600 ring-1 ring-blue-200 transition hover:bg-blue-50 dark:bg-transparent dark:text-blue-200 dark:ring-blue-400/40 dark:hover:bg-white/5">Alle Einstellungen</a>
</div>
</div>
</div>
<!-- Fuß-Navigation -->
<div class="mt-8 flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 pt-5 dark:border-gray-700">
<button type="button" id="einrZurueck"
class="inline-flex items-center gap-1.5 rounded-md px-3.5 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
Zurück
</button>
<div class="flex items-center gap-2">
<button type="button" id="einrDirekt"
class="hidden text-sm font-medium text-blue-600 hover:underline dark:text-sky-400">
Bewerbung manuell anlegen
</button>
<button type="button" id="einrUeberspringen"
class="rounded-md px-3.5 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700">
Überspringen
</button>
<button type="button" id="einrWeiter"
class="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60">
<span id="einrWeiterLabel">Los geht's</span>
<svg id="einrWeiterIcon" class="h-4 w-4" 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"/></svg>
<svg id="einrSpinner" class="hidden h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/></svg>
</button>
</div>
</div>
<p id="einrFehler" class="mt-3 hidden text-sm text-red-600 dark:text-red-400"></p>
</form>
</div>
</section>
<script>
(function () {
var form = document.getElementById('einrForm');
if (!form) return;
var MAX = 7;
var step = 1;
var panels = {};
Array.prototype.slice.call(form.querySelectorAll('.einr-panel')).forEach(function (el) {
panels[el.getAttribute('data-panel')] = el;
});
var stepEls = Array.prototype.slice.call(document.querySelectorAll('#einrSteps .einr-step'));
// Vorhandene Secrets kennen wir nur als Flag — der Wert steht nie im Formular.
var VORHANDEN = {
apiKey: <%= setupWizard.hasApiKey ? 'true' : 'false' %>,
mailPassword: <%= (c.hasMailPassword) ? 'true' : 'false' %>,
apiToken: <%= (c.hasApiToken) ? 'true' : 'false' %>
};
var btnZurueck = document.getElementById('einrZurueck');
var btnUeberspringen = document.getElementById('einrUeberspringen');
var btnWeiter = document.getElementById('einrWeiter');
var lblWeiter = document.getElementById('einrWeiterLabel');
var iconWeiter = document.getElementById('einrWeiterIcon');
var spinner = document.getElementById('einrSpinner');
var btnDirekt = document.getElementById('einrDirekt');
var btnAusblenden = document.getElementById('einrAusblenden');
var fehler = document.getElementById('einrFehler');
var labels = { 1: 'Los geht\'s', 2: 'Weiter', 3: 'Weiter', 4: 'Weiter', 5: 'Weiter', 6: 'Weiter', 7: 'Abschließen' };
function v(sel) { var el = form.querySelector(sel); return el ? el.value : ''; }
function showFehler(msg) { fehler.textContent = msg || ''; fehler.classList.toggle('hidden', !msg); }
function setBusy(busy) {
btnWeiter.disabled = busy;
spinner.classList.toggle('hidden', !busy);
iconWeiter.classList.toggle('hidden', busy);
}
function check(r) { if (!r.ok || !r.json.success) throw new Error(r.json.error || 'Speichern fehlgeschlagen'); }
function render() {
Object.keys(panels).forEach(function (n) { panels[n].classList.toggle('hidden', String(n) !== String(step)); });
stepEls.forEach(function (el) {
var n = Number(el.getAttribute('data-step'));
var dot = el.querySelector('.einr-step-dot');
el.classList.toggle('is-active', n === step);
if (n < step) {
dot.style.backgroundColor = 'rgba(255,255,255,0.92)';
dot.style.color = '#1d4ed8';
dot.innerHTML = '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>';
} else if (n === step) {
dot.style.backgroundColor = '#ffffff';
dot.style.color = '#1d4ed8';
dot.textContent = String(n);
} else {
dot.style.backgroundColor = '';
dot.style.color = '';
dot.textContent = String(n);
}
});
btnZurueck.classList.toggle('hidden', step === 1);
btnUeberspringen.classList.toggle('hidden', step === 1 || step === MAX);
btnDirekt.classList.toggle('hidden', step !== 1 && step !== MAX);
lblWeiter.textContent = labels[step] || 'Weiter';
}
function gehZu(n) {
step = Math.max(1, Math.min(MAX, n));
if (step === MAX) { aktualiereStatus(); }
render();
var sec = document.getElementById('einrichtung');
if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ico für Pflicht-Checkliste: grün = erledigt, orange = offen.
function setzeCheck(name, ok) {
var li = form.querySelector('[data-check="' + name + '"]');
if (!li) return;
var ico = li.querySelector('.einr-ico');
ico.style.backgroundColor = ok ? 'rgba(16,185,129,0.12)' : 'rgba(245,158,11,0.12)';
ico.style.color = ok ? '#059669' : '#d97706';
ico.innerHTML = ok
? '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>'
: '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01"/></svg>';
}
// ico für optionale Verbindungen: grün = eingerichtet, neutral = optional/offen.
function setzeOpt(name, ok) {
var li = form.querySelector('[data-opt="' + name + '"]');
if (!li) return;
var ico = li.querySelector('.einr-ico');
ico.style.backgroundColor = ok ? 'rgba(16,185,129,0.12)' : 'rgba(148,163,184,0.15)';
ico.style.color = ok ? '#059669' : '#94a3b8';
ico.innerHTML = ok
? '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>'
: '<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14"/></svg>';
}
function aktualiereStatus() {
setzeCheck('profil', !!v('#einr-name').trim());
setzeCheck('anschreiben', !!v('#einr-anschreiben').trim());
setzeCheck('lebenslauf', !!v('#einr-lebenslauf').trim());
setzeCheck('ki', !!v('#einr-apikey').trim() || VORHANDEN.apiKey);
setzeOpt('mail', !!v('#einr-mail-host').trim());
setzeOpt('kalender', !!v('#einr-caldav-url').trim());
setzeOpt('api', !!v('#einr-api-token').trim() || VORHANDEN.apiToken);
}
function post(url, data) {
var body = new URLSearchParams();
Object.keys(data).forEach(function (k) { body.append(k, data[k]); });
return fetch(url, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' } })
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); });
}
// Speichert den Inhalt des aktuellen Schritts vor dem Weiterspringen.
function speichern(stepNr) {
if (stepNr === 2) {
if (!v('#einr-name').trim()) {
showFehler('Bitte gib mindestens deinen Namen ein — das ist das einzige Pflichtfeld.');
return Promise.reject();
}
return post('/api/einrichtung/persoenlich', {
name: v('#einr-name'), adresse: v('#einr-adresse'), kundennummer: v('#einr-kundennummer'),
email: v('#einr-email'), telefon: v('#einr-telefon'), ort: v('#einr-ort'),
webseite: v('#einr-webseite'), geburtsdatum: v('#einr-geburtsdatum')
}).then(check);
}
if (stepNr === 3) {
return post('/api/einrichtung/basis', { typ: 'Anschreiben', inhalt: v('#einr-anschreiben') })
.then(function () { return post('/api/einrichtung/basis', { typ: 'Lebenslauf', inhalt: v('#einr-lebenslauf') }); })
.then(check);
}
if (stepNr === 4) {
return post('/api/einrichtung/ollama', {
OLLAMA_API_KEY: v('#einr-apikey'), OLLAMA_MODEL: v('#einr-model'), OLLAMA_HOST: v('#einr-host')
}).then(check);
}
if (stepNr === 5) {
var data = {
MAIL_HOST: v('#einr-mail-host'), MAIL_SMTP_PORT: v('#einr-mail-smtp-port'),
MAIL_IMAP_PORT: v('#einr-mail-imap-port'), MAIL_USER: v('#einr-mail-user'),
MAIL_FROM_NAME: v('#einr-mail-fromname'), MAIL_FROM: v('#einr-mail-from'),
MAIL_IMAP_MAILBOX: v('#einr-mail-mailbox'), MAIL_POLL_MS: v('#einr-mail-poll'),
CALDAV_URL: v('#einr-caldav-url'), CALDAV_ALARM_MIN: v('#einr-caldav-alarm'),
CALDAV_POLL_MS: v('#einr-caldav-poll')
};
var pw = v('#einr-mail-password');
if (pw) data.MAIL_PASSWORD = pw; // leer = vorhandenes Passwort nicht überschreiben
return post('/api/einrichtung/config', data).then(check);
}
if (stepNr === 6) {
var token = v('#einr-api-token');
var data = {};
if (token) data.API_TOKEN = token; // leer = vorhandenen Token nicht anfassen
return post('/api/einrichtung/config', data).then(check);
}
return Promise.resolve();
}
btnWeiter.addEventListener('click', function () {
showFehler('');
if (step === MAX) {
post('/api/einrichtung/verstecken', {}).then(function () { window.location.href = '/'; });
return;
}
setBusy(true);
speichern(step).then(function () {
// Nach dem Speichern ggf. „vorhanden"-Flags aktualisieren, damit die
// Abschluss-Checkliste korrekt steht (z. B. neu erzeugter API-Token).
if (step === 4 && v('#einr-apikey')) VORHANDEN.apiKey = VORHANDEN.apiKey || !!v('#einr-apikey').trim();
if (step === 5 && v('#einr-mail-password')) VORHANDEN.mailPassword = VORHANDEN.mailPassword || !!v('#einr-mail-password').trim();
if (step === 6 && v('#einr-api-token')) VORHANDEN.apiToken = VORHANDEN.apiToken || !!v('#einr-api-token').trim();
setBusy(false);
gehZu(step + 1);
}).catch(function (e) {
setBusy(false);
if (e && e.message) showFehler(e.message);
});
});
btnZurueck.addEventListener('click', function () { showFehler(''); gehZu(step - 1); });
btnUeberspringen.addEventListener('click', function () { showFehler(''); gehZu(step + 1); });
btnDirekt.addEventListener('click', function () { if (typeof window.openAddApplicationModal === 'function') window.openAddApplicationModal(); });
btnAusblenden.addEventListener('click', function () { post('/api/einrichtung/verstecken', {}).then(function () { window.location.href = '/'; }); });
// Reveal-Toggle für alle Passwort-/Secret-Felder (ein shared Handler).
Array.prototype.slice.call(form.querySelectorAll('.einr-reveal')).forEach(function (btn) {
btn.addEventListener('click', function () {
var inp = document.getElementById(btn.getAttribute('data-target'));
if (inp) inp.type = inp.type === 'password' ? 'text' : 'password';
});
});
// API-Token neu generieren (32 Byte hex, clientseitig CSPRNG).
var gen = document.getElementById('einr-token-gen');
var copy = document.getElementById('einr-token-copy');
var hinweis = document.getElementById('einr-token-hint');
function tokenHint(text, ok) {
hinweis.textContent = text;
hinweis.classList.remove('hidden');
hinweis.classList.toggle('text-amber-600', !ok); hinweis.classList.toggle('dark:text-amber-400', !ok);
hinweis.classList.toggle('text-green-600', ok); hinweis.classList.toggle('dark:text-green-400', ok);
}
if (gen) gen.addEventListener('click', function () {
var inp = document.getElementById('einr-api-token');
if (!inp) return;
var bytes = new Uint8Array(32); crypto.getRandomValues(bytes);
inp.value = Array.from(bytes).map(function (b) { return b.toString(16).padStart(2, '0'); }).join('');
inp.type = 'text';
tokenHint('Neuer Token erzeugt — zum Aktivieren „Weiter". Der alte wird damit ungültig.', false);
});
if (copy) copy.addEventListener('click', async function () {
var inp = document.getElementById('einr-api-token');
if (!inp || !inp.value) return;
try { await navigator.clipboard.writeText(inp.value); tokenHint('In die Zwischenablage kopiert.', true); }
catch (e) { inp.type = 'text'; inp.select(); tokenHint('Kopieren nicht möglich — Wert ist markiert.', false); }
});
render();
})();
</script>
+25
View File
@@ -0,0 +1,25 @@
<%
// Stern-Button zum Merken/Entmerken. Generisch einsetzbar für Jobangebote
// (Standard-Endpunkt /jobangebote/:id/favorit) und Bewerbungen — dann `endpoint`
// mitgeben. Entweder `j` (legacy, braucht id + favorit) oder direkt `id`/`favorit`.
// Zustand wird über die Klasse .is-fav gesteuert (definiert in favorit-script).
// Mit `compact` wird ein reines Icon-Button (für Listenzeilen). Das Skript aus
// partials/favorit-script bedient Klicks per Delegation — nur einmal pro Seite
// einbinden.
var favItem = (typeof item !== 'undefined' && item) ? item : (typeof j !== 'undefined' ? j : null);
var favId = (typeof id !== 'undefined' && id != null) ? id : (favItem && favItem.id);
var favAktiv = !!(typeof favorit !== 'undefined' ? favorit : (favItem && favItem.favorit));
var favEndpoint = (typeof endpoint !== 'undefined' && endpoint)
? endpoint
: ('/jobangebote/' + encodeURIComponent(favId) + '/favorit');
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 <%= 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 %>"
title="<%= favAktiv ? 'Favorit entfernen' : 'Als Favorit merken' %>"
aria-pressed="<%= favAktiv ? 'true' : 'false' %>">
<svg class="fav-ico h-4 w-4" viewBox="0 0 24 24" fill="<%= favAktiv ? 'currentColor' : 'none' %>" stroke="currentColor" stroke-width="2">
<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>
<% if (!favCompact) { %><span class="fav-label"><%= favAktiv ? 'Gemerkt' : 'Merken' %></span><% } else { %><span class="fav-label hidden"><%= favAktiv ? 'Gemerkt' : 'Merken' %></span><% } %>
</button>
+87
View File
@@ -0,0 +1,87 @@
<style>
/* Stern-Button: neutral grau, gemerkt amber. Klasse .is-fav steuert den
Zustand; das Skript unten schaltet sie per Klick um (optimistisch). */
.fav-star { color: rgb(107 114 128); } /* gray-500 */
.fav-star:hover { background-color: rgb(243 244 246); } /* gray-100 */
.dark .fav-star { color: rgb(209 213 219); }
.dark .fav-star:hover { background-color: rgb(55 65 81); } /* gray-700 */
.fav-star.is-fav {
color: rgb(217 119 6); /* amber-600 */
background-color: rgb(255 251 235); /* amber-50 */
border-color: rgb(252 211 77); /* amber-300 */
}
.dark .fav-star.is-fav {
color: rgb(252 211 77); /* amber-300 */
background-color: rgba(245, 158, 11, 0.10);
border-color: rgba(251, 191, 36, 0.40);
}
</style>
<script>
(function () {
if (window.__favStarBound) return;
window.__favStarBound = true;
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-favorit]');
if (!btn) return;
e.preventDefault();
var id = btn.getAttribute('data-favorit');
var aktiv = btn.getAttribute('data-aktiv') === '1';
var endpoint = btn.getAttribute('data-endpoint') || ('/jobangebote/' + encodeURIComponent(id) + '/favorit');
// Optimistisch umschalten, sofort sichtbar.
setzeBtn(btn, !aktiv);
fetch(endpoint, {
method: 'POST',
headers: { 'X-Requested-With': 'fetch' }
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); })
.then(function (r) {
if (r.ok && r.json && r.json.success) {
setzeBtn(btn, !!r.json.favorit);
// Zeile ausblenden, sobald sie nicht mehr in die aktuelle
// Ansicht gehört: auf der Offen-Seite beim Merken (favorit=1),
// auf der Gemerkt-Seite / Startseite beim Entfernen (favorit=0).
entferneFavoritZeile(btn, !!r.json.favorit);
} else {
setzeBtn(btn, aktiv); // Server hat abgelehnt -> zurückrollen
if (r.json && r.json.error) console.warn('Favorit:', r.json.error);
}
})
.catch(function () { setzeBtn(btn, aktiv); });
});
function setzeBtn(btn, aktiv) {
btn.setAttribute('data-aktiv', aktiv ? '1' : '0');
btn.setAttribute('aria-pressed', aktiv ? 'true' : 'false');
btn.setAttribute('title', aktiv ? 'Favorit entfernen' : 'Als Favorit merken');
btn.classList.toggle('is-fav', !!aktiv);
var ico = btn.querySelector('.fav-ico');
if (ico) ico.setAttribute('fill', aktiv ? 'currentColor' : 'none');
var lbl = btn.querySelector('.fav-label');
if (lbl) lbl.textContent = aktiv ? 'Gemerkt' : 'Merken';
}
function entferneFavoritZeile(btn, istFavorit) {
var item = btn.closest('[data-fav-item]');
if (!item) return; // keine ausblendbare Zeile -> nur optisch umschalten
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();
// 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) {
card.style.transition = 'opacity 0.2s ease';
card.style.opacity = '0';
setTimeout(function () { card.remove(); }, 180);
}
}
})();
</script>
+24 -5
View File
@@ -1,8 +1,27 @@
<footer class="bg-gray-100 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-12 py-6">
<div class="container mx-auto px-4 text-center">
<p class="text-gray-600 dark:text-gray-400 text-sm">
Bewerbungs-Tracker für Jobcenter Grundsicherung |
<span id="currentYear"></span>
<footer class="mt-12 border-t border-gray-200/80 dark:border-white/5">
<div class="container mx-auto px-4 py-8 text-center">
<p class="text-xs text-gray-400 dark:text-slate-500">
NextJobs · &copy; nextjobs.cc <span id="currentYear"><%= new Date().getFullYear() %></span>
</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>
</footer>
<!-- Floating button: open the KI chat assistant -->
<a href="/chat"
class="fixed bottom-5 right-5 z-40 flex items-center gap-2 rounded-full bg-blue-600 hover:bg-blue-700 text-white shadow-lg px-4 py-3 text-sm font-medium transition-colors"
title="KI-Bewerbungs-Assistent öffnen">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.86 9.86 0 01-4-.8L3 20l1.3-3.9A7.97 7.97 0 013 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
</svg>
<span class="hidden sm:inline">KI-Chat</span>
</a>
+15 -3
View File
@@ -1,11 +1,23 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bewerbungs-Tracker</title>
<meta name="theme-color" content="#2563eb">
<title>NextJobs</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- Apply the dark class before first paint to avoid a light-mode flash (FOUC).
Theme follows the browser/OS setting (prefers-color-scheme) — no manual toggle. -->
<script>
(function () {
try {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
} catch (e) { /* ignore */ }
})();
</script>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
// Toggle dark mode via the `.dark` class (default is `media`, i.e. OS-driven)
tailwind.config = { darkMode: 'class' };
</script>
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/styles.css">
<link rel="stylesheet" href="/css/styles.css?v=<%= assetVersion %>">
+404 -73
View File
@@ -1,101 +1,432 @@
<header class="bg-gradient-to-r from-blue-700 to-blue-900 dark:from-gray-800 dark:to-gray-900 shadow-lg">
<div class="container mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<a href="/" class="flex items-center space-x-3">
<svg class="w-8 h-8 text-white" 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>
<%
// Current path (res.locals.pfad, set in the auth middleware) drives the active
// state. The old header had none — you could never tell which section you were
// in. Copied into a differently named local: re-declaring `pfad` here would
// shadow the EJS local under `with(locals)`.
var aktPfad = (typeof pfad !== 'undefined' && pfad) ? String(pfad) : '/';
// Bewerbungen owns the dashboard and every single application page.
var istBewerbungen = (aktPfad === '/' || aktPfad.indexOf('/bewerbung') === 0);
// Offene Angebote only — "übernommen" lives under Stellensuche, with the rest
// of the search machinery.
var istAngebote = (aktPfad === '/jobangebote');
var istPostfach = (aktPfad.indexOf('/postfach') === 0);
var istVorlagen = (aktPfad.indexOf('/vorlagen') === 0);
var istStatistik = (aktPfad.indexOf('/statistik') === 0);
// The search machinery: how offers get found, what was taken, what is blocked.
var istSuchprofil = (aktPfad.indexOf('/jobsuche') === 0);
var istUebernommen = (aktPfad.indexOf('/jobangebote/uebernommen') === 0);
var istBlacklist = (aktPfad.indexOf('/blacklist') === 0);
var istStellensuche = istSuchprofil || istUebernommen || istBlacklist;
var istEinstellungen = (aktPfad.indexOf('/einstellungen') === 0);
var istAdmin = (aktPfad.indexOf('/admin') === 0);
var angemeldet = (typeof user !== 'undefined' && user);
var initiale = angemeldet ? String(user.username || '?').charAt(0).toUpperCase() : '?';
%>
<!-- The bar carries no surface of its own: it takes the page background, so it
reads as part of the page rather than as a lid on top of it. Held apart from
the content only by a hairline and, once scrolled, the faintest shadow.
Slightly translucent + blurred so content passing underneath stays quiet. -->
<header id="siteHeader"
class="sticky top-0 z-50 border-b border-gray-200/80 bg-gray-50/85 backdrop-blur-md transition-shadow duration-300 dark:border-white/5 dark:bg-gray-900/85">
<div class="container mx-auto px-4">
<div class="flex h-16 items-center gap-3">
<!-- Brand -->
<a href="/" class="nav-brand group flex shrink-0 items-center gap-2.5 rounded-lg">
<span class="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-sky-400 to-blue-600 shadow-sm shadow-blue-600/20 ring-1 ring-black/5">
<svg class="h-5 w-5 text-white" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" 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>
<h1 class="text-xl font-bold text-white">Bewerbungs-Tracker</h1>
</span>
<span class="whitespace-nowrap text-[15px] font-semibold leading-none tracking-tight text-gray-900 dark:text-white">
NextJobs
</span>
</a>
<div class="flex items-center space-x-4">
<!-- Postfach (unlinked incoming e-mails) link -->
<a href="/postfach"
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="E-Mails im Postfach, die noch keiner Bewerbung zugeordnet sind">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<span class="hidden sm:inline">Postfach</span>
<span id="unassignedBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-red-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
<!-- Primary navigation (desktop), right-aligned next to the account
cluster. Order follows the actual workflow: finden -> bewerben ->
antworten. -->
<nav class="ml-auto hidden items-center gap-1 lg:flex" aria-label="Hauptnavigation">
<a href="/" class="nav-item <%= istBewerbungen ? 'is-active' : '' %>">
<span>Bewerbungen</span>
<span class="nav-rail"></span>
</a>
<!-- Jobangebote (ingested via third-party REST API) link -->
<a href="/jobangebote"
id="jobangeboteLink"
class="relative flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="Über die REST-API eingespielte Jobangebote">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<span class="hidden sm:inline">Jobangebote</span>
<span id="jobangeboteBadge" class="hidden absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-green-500 text-white text-[10px] font-bold ring-2 ring-blue-800 dark:ring-gray-900">0</span>
<a href="/jobangebote" class="nav-item <%= istAngebote ? 'is-active' : '' %>">
<span>Jobangebote</span>
<span class="js-badge-angebote nav-badge nav-badge--green hidden">0</span>
<span class="nav-rail"></span>
</a>
<!-- Blacklist (blocked job offers) link -->
<a href="/blacklist"
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="Geblockte Jobangebote (Blacklist) verwalten">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
<!-- Stellensuche: everything that produces or filters offers. -->
<div class="relative">
<button type="button" data-dd="ddSuche" aria-expanded="false" aria-haspopup="true"
class="nav-item <%= istStellensuche ? 'is-active' : '' %>">
<span>Stellensuche</span>
<svg class="dd-chevron h-3.5 w-3.5 text-slate-500 transition-transform duration-200" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path>
</svg>
<span class="hidden sm:inline">Blacklist</span>
<span class="nav-rail"></span>
</button>
<div id="ddSuche" class="dd-panel hidden w-80">
<a href="/jobsuche" class="dd-item <%= istSuchprofil ? 'is-active' : '' %>">
<svg class="dd-icon" 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>
<span class="dd-title">Suchprofil &amp; Läufe</span>
<span class="dd-desc">Wo und wann automatisch gesucht wird</span>
</span>
</a>
<a href="/jobangebote/uebernommen" class="dd-item <%= istUebernommen ? 'is-active' : '' %>">
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"></path>
</svg>
<span>
<span class="dd-title">Übernommene Angebote</span>
<span class="dd-desc">Bereits in eine Bewerbung überführt</span>
</span>
</a>
<a href="/blacklist" class="dd-item <%= istBlacklist ? 'is-active' : '' %>">
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
<span>
<span class="dd-title">Blacklist</span>
<span class="dd-desc">Taucht in keinem Suchlauf wieder auf</span>
</span>
</a>
</div>
</div>
<a href="/postfach" class="nav-item <%= istPostfach ? 'is-active' : '' %>">
<span>Postfach</span>
<span class="js-badge-postfach nav-badge nav-badge--red hidden">0</span>
<span class="nav-rail"></span>
</a>
<!-- Vorlagen (base documents) link -->
<a href="/vorlagen"
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-white/20 hover:bg-white/30 transition-colors text-white text-sm font-medium"
title="Basis-Unterlagen für die KI-Generierung verwalten">
<svg class="w-5 h-5" 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="hidden sm:inline">Vorlagen</span>
<a href="/vorlagen" class="nav-item <%= istVorlagen ? 'is-active' : '' %>">
<span>Vorlagen</span>
<span class="nav-rail"></span>
</a>
<!-- Dark mode toggle -->
<button
id="darkModeToggle"
class="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
aria-label="Dark Mode umschalten"
>
<svg id="sunIcon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
<a href="/statistik" class="nav-item <%= istStatistik ? 'is-active' : '' %>">
<span>Statistik</span>
<span class="nav-rail"></span>
</a>
</nav>
<!-- Right cluster: notifications + account. Below lg the nav is hidden,
so `ml-auto` keeps this pinned right; from lg the nav already claims
the free space and the cluster just follows it. -->
<div class="ml-auto flex items-center gap-1.5 lg:ml-3">
<!-- Notifications. `dd-anchor`: auf schmalen Schirmen spannt sich das
Panel über den Header statt am Knopf zu kleben (siehe styles.css). -->
<div class="dd-anchor relative">
<button id="notifBtn" type="button" data-dd="notifPanel" aria-expanded="false"
class="icon-btn" aria-label="Benachrichtigungen" title="Ungelesene E-Mails">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
</svg>
<svg id="moonIcon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
<span id="notifBadge" class="dot-badge dot-badge--red hidden">0</span>
</button>
<div id="notifPanel" class="dd-panel hidden w-80 max-w-[calc(100vw-2rem)] p-0">
<div class="sticky top-0 flex items-center justify-between rounded-t-xl border-b border-gray-100 bg-white px-4 py-2.5 dark:border-white/5 dark:bg-slate-800">
<span class="text-sm font-semibold text-gray-800 dark:text-white">Benachrichtigungen</span>
<button id="notifMarkAll" type="button" class="hidden text-xs font-medium text-blue-600 hover:underline dark:text-sky-400">Alle gelesen</button>
</div>
<ul id="notifList" class="max-h-96 divide-y divide-gray-100 overflow-y-auto dark:divide-white/5"></ul>
<div id="notifEmpty" class="px-4 py-10 text-center text-sm text-gray-400 dark:text-slate-500">
Keine ungelesenen Nachrichten
</div>
</div>
</div>
<% if (angemeldet) { %>
<!-- Account -->
<div class="dd-anchor relative">
<button type="button" data-dd="ddUser" aria-expanded="false" aria-haspopup="true"
class="nav-item !gap-2 !py-1.5 !pl-1.5 !pr-2">
<span class="grid h-7 w-7 place-items-center rounded-full bg-blue-600/10 text-[11px] font-semibold text-blue-700 ring-1 ring-blue-600/20 dark:bg-white/10 dark:text-white dark:ring-white/15">
<%= initiale %>
</span>
<span class="hidden max-w-[10rem] truncate font-medium md:inline"><%= user.username %></span>
<svg class="dd-chevron hidden h-3.5 w-3.5 text-slate-500 transition-transform duration-200 md:block" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- Settings button -->
<% if (typeof hideSettings === 'undefined' || !hideSettings) { %>
<button
id="settingsBtn"
class="p-2 rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
aria-label="Einstellungen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<div id="ddUser" class="dd-panel hidden w-60">
<div class="border-b border-gray-100 px-3 pb-2.5 pt-1 dark:border-white/5">
<p class="truncate text-sm font-semibold text-gray-800 dark:text-white"><%= user.username %></p>
<p class="text-xs text-gray-500 dark:text-slate-400">
<%= user.is_admin ? 'Administrator' : 'Benutzer' %>
</p>
</div>
<div class="pt-1">
<a href="/einstellungen" class="dd-item <%= istEinstellungen ? 'is-active' : '' %>">
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.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>
</button>
<span class="dd-title">Einstellungen</span>
</a>
<% if (user.is_admin) { %>
<a href="/admin" class="dd-item <%= istAdmin ? 'is-active' : '' %>">
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2a4 4 0 100-8 4 4 0 000 8z"></path>
</svg>
<span class="dd-title">Benutzer verwalten</span>
</a>
<% } %>
</div>
<div class="mt-1 border-t border-gray-100 pt-1 dark:border-white/5">
<a href="/logout" class="dd-item dd-item--danger">
<svg class="dd-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
</svg>
<span class="dd-title">Abmelden</span>
</a>
</div>
</div>
</div>
<% } %>
<!-- Mobile menu -->
<button type="button" id="burgerBtn" aria-expanded="false" aria-controls="mobileNav"
class="icon-btn lg:hidden" aria-label="Menü">
<svg id="burgerOpen" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
<svg id="burgerClose" class="hidden h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
</div>
<% if (typeof impersonator !== 'undefined' && impersonator) { %>
<!-- Impersonation banner: unmistakable and always present while an admin is
acting as another user. The "switch back" form is a real POST so it works
without JS; admin rights are suspended until the switch back. -->
<div class="border-t border-amber-300/60 bg-amber-50/95 backdrop-blur-md dark:border-amber-400/20 dark:bg-amber-500/10">
<div class="container mx-auto flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-2 text-sm text-amber-800 dark:text-amber-200">
<svg class="h-4 w-4 shrink-0" 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 class="font-medium">Impersonation aktiv:</span>
<span>du handelst als <strong class="font-semibold"><%= user.username %></strong> (eingeloggt durch <strong class="font-semibold"><%= impersonator.username %></strong>). Admin-Aktionen sind pausiert.</span>
<form method="POST" action="/admin/impersonate/stop" class="ml-auto">
<button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-amber-600 hover:bg-amber-700 px-3 py-1.5 text-xs font-medium text-white transition-colors">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Zurück zum Admin
</button>
</form>
</div>
</div>
<% } %>
<!-- Mobile navigation: the same structure, spelled out. The old header just
dropped the labels and left seven unlabelled icons. -->
<div id="mobileNav" class="hidden border-t border-gray-200/80 bg-gray-50 lg:hidden dark:border-white/5 dark:bg-gray-900">
<nav class="container mx-auto space-y-4 px-4 py-4" aria-label="Hauptnavigation (mobil)">
<div class="space-y-0.5">
<a href="/" class="m-item <%= istBewerbungen ? 'is-active' : '' %>">Bewerbungen</a>
<a href="/jobangebote" class="m-item <%= istAngebote ? 'is-active' : '' %>">
Jobangebote
<span class="js-badge-angebote nav-badge nav-badge--green ml-auto hidden">0</span>
</a>
<a href="/postfach" class="m-item <%= istPostfach ? 'is-active' : '' %>">
Postfach
<span class="js-badge-postfach nav-badge nav-badge--red ml-auto hidden">0</span>
</a>
<a href="/vorlagen" class="m-item <%= istVorlagen ? 'is-active' : '' %>">Vorlagen</a>
<a href="/statistik" class="m-item <%= istStatistik ? 'is-active' : '' %>">Statistik</a>
</div>
<div class="space-y-0.5">
<p class="m-label">Stellensuche</p>
<a href="/jobsuche" class="m-item <%= istSuchprofil ? 'is-active' : '' %>">Suchprofil &amp; Läufe</a>
<a href="/jobangebote/uebernommen" class="m-item <%= istUebernommen ? 'is-active' : '' %>">Übernommene Angebote</a>
<a href="/blacklist" class="m-item <%= istBlacklist ? 'is-active' : '' %>">Blacklist</a>
</div>
<% if (angemeldet) { %>
<div class="space-y-0.5">
<p class="m-label">Konto</p>
<p class="px-3 pb-1 text-sm text-gray-900 dark:text-white">
<%= user.username %>
<span class="text-gray-500 dark:text-slate-500">· <%= user.is_admin ? 'Administrator' : 'Benutzer' %></span>
</p>
<a href="/einstellungen" class="m-item <%= istEinstellungen ? 'is-active' : '' %>">Einstellungen</a>
<% if (user.is_admin) { %>
<a href="/admin" class="m-item <%= istAdmin ? 'is-active' : '' %>">Benutzer verwalten</a>
<% } %>
<a href="/logout" class="m-item !text-rose-600 hover:!bg-rose-50 dark:!text-rose-300 dark:hover:!bg-rose-500/10">Abmelden</a>
</div>
<% } %>
</nav>
</div>
<script>
// Populate the Postfach badge with the count of unlinked incoming e-mails.
// --- Dropdowns (Stellensuche, Konto, Benachrichtigungen) ---------------
// One controller for all of them: click toggles, opening one closes the
// others, Escape and outside-click close, aria-expanded stays truthful.
(function () {
fetch('/api/emails/unassigned-count').then(function (r) { return r.json(); }).then(function (d) {
var b = document.getElementById('unassignedBadge');
if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); }
}).catch(function () { /* ignore — badge stays hidden */ });
var trigger = Array.prototype.slice.call(document.querySelectorAll('[data-dd]'));
function panelOf(btn) { return document.getElementById(btn.getAttribute('data-dd')); }
function close(btn) {
var p = panelOf(btn);
if (!p) return;
p.classList.add('hidden');
btn.setAttribute('aria-expanded', 'false');
var chev = btn.querySelector('.dd-chevron');
if (chev) chev.classList.remove('rotate-180');
}
function closeAll(ausser) {
trigger.forEach(function (b) { if (b !== ausser) close(b); });
}
trigger.forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var p = panelOf(btn);
if (!p) return;
var offen = !p.classList.contains('hidden');
closeAll(btn);
if (offen) { close(btn); return; }
p.classList.remove('hidden');
btn.setAttribute('aria-expanded', 'true');
var chev = btn.querySelector('.dd-chevron');
if (chev) chev.classList.add('rotate-180');
btn.dispatchEvent(new CustomEvent('dd:open'));
});
});
document.addEventListener('click', function (e) {
trigger.forEach(function (btn) {
var p = panelOf(btn);
if (p && !p.classList.contains('hidden') && !p.contains(e.target) && !btn.contains(e.target)) {
close(btn);
}
});
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeAll(null);
});
// --- Mobile menu ---
var burger = document.getElementById('burgerBtn');
var mob = document.getElementById('mobileNav');
if (burger && mob) {
burger.addEventListener('click', function (e) {
e.stopPropagation();
var offen = mob.classList.toggle('hidden');
burger.setAttribute('aria-expanded', String(!offen));
document.getElementById('burgerOpen').classList.toggle('hidden', !offen);
document.getElementById('burgerClose').classList.toggle('hidden', offen);
});
}
// The bar has no surface of its own, so once content starts sliding
// underneath it needs the faintest separation — a hairline shadow, not
// a drop shadow.
var head = document.getElementById('siteHeader');
function onScroll() {
if (!head) return;
head.classList.toggle('is-scrolled', window.scrollY > 4);
}
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
})();
// Jobangebote badge: count of open offers ingested via the REST API.
// --- Notification bell: unread received e-mails ------------------------
(function () {
fetch('/jobangebote/anzahl-offen').then(function (r) { return r.json(); }).then(function (d) {
var b = document.getElementById('jobangeboteBadge');
if (b && d && d.count > 0) { b.textContent = d.count; b.classList.remove('hidden'); }
}).catch(function () { /* ignore */ });
var btn = document.getElementById('notifBtn');
var badge = document.getElementById('notifBadge');
var list = document.getElementById('notifList');
var empty = document.getElementById('notifEmpty');
var markAll = document.getElementById('notifMarkAll');
if (!btn) return;
function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
function fmtDate(d) {
if (!d) return '';
try {
var dt = new Date(d), diff = (Date.now() - dt.getTime()) / 1000;
if (diff < 60) return 'gerade eben';
if (diff < 3600) return Math.floor(diff / 60) + ' Min.';
if (diff < 86400) return Math.floor(diff / 3600) + ' Std.';
return dt.toLocaleDateString('de-DE');
} catch (e) { return ''; }
}
function render(data) {
var items = (data && data.items) || [], count = (data && data.count) || 0;
if (count > 0) { badge.textContent = count > 99 ? '99+' : count; badge.classList.remove('hidden'); }
else { badge.classList.add('hidden'); }
list.innerHTML = '';
if (!items.length) { empty.classList.remove('hidden'); markAll.classList.add('hidden'); return; }
empty.classList.add('hidden'); markAll.classList.remove('hidden');
items.forEach(function (it) {
var li = document.createElement('li');
var a = document.createElement('a');
a.href = it.url;
a.className = 'block px-4 py-3 transition-colors hover:bg-gray-50 dark:hover:bg-white/5';
a.innerHTML =
'<div class="flex items-center justify-between gap-2">'
+ '<span class="truncate text-sm font-medium text-gray-800 dark:text-gray-100">' + esc(it.from) + '</span>'
+ '<span class="shrink-0 text-[11px] text-gray-400">' + esc(fmtDate(it.date)) + '</span>'
+ '</div>'
+ '<div class="truncate text-sm text-gray-700 dark:text-gray-200">' + esc(it.subject) + '</div>'
+ (it.snippet ? '<div class="mt-0.5 truncate text-xs text-gray-500 dark:text-slate-400">' + esc(it.snippet) + '</div>' : '')
+ (it.kontext ? '<div class="mt-0.5 truncate text-[11px] text-blue-600 dark:text-sky-400">' + esc(it.kontext) + '</div>' : '');
li.appendChild(a); list.appendChild(li);
});
}
function load() {
fetch('/api/notifications', { cache: 'no-store' })
.then(function (r) { return r.json(); }).then(render).catch(function () { /* ignore */ });
}
btn.addEventListener('dd:open', load); // refresh whenever the panel opens
markAll.addEventListener('click', function (e) {
e.preventDefault(); e.stopPropagation();
fetch('/api/emails/mark-all-read', { method: 'POST' })
.then(function () { render({ count: 0, items: [] }); }).catch(function () { /* ignore */ });
});
load();
setInterval(load, 60000);
})();
// --- Counters: unlinked mail + open job offers -------------------------
// Class-based, because each count now shows in both the desktop bar and
// the mobile menu.
(function () {
function setzen(selektor, count) {
if (!(count > 0)) return;
document.querySelectorAll(selektor).forEach(function (el) {
el.textContent = count > 99 ? '99+' : count;
el.classList.remove('hidden');
});
}
fetch('/api/emails/unassigned-count').then(function (r) { return r.json(); })
.then(function (d) { setzen('.js-badge-postfach', d && d.count); }).catch(function () { /* ignore */ });
fetch('/jobangebote/anzahl-offen').then(function (r) { return r.json(); })
.then(function (d) { setzen('.js-badge-angebote', d && d.count); }).catch(function () { /* ignore */ });
})();
</script>
</header>
+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>
+12
View File
@@ -0,0 +1,12 @@
<%
// Display-only label chips. Expects local `labels` (array of label strings).
var _labelClasses = {
'Regional': 'bg-sky-100 text-sky-800 dark:bg-sky-900/50 dark:text-sky-200',
'Remote-Deutschlandweit': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/50 dark:text-emerald-200',
'Homeoffice-Deutschlandweit': 'bg-violet-100 text-violet-800 dark:bg-violet-900/50 dark:text-violet-200'
};
var _labels = (typeof labels !== 'undefined' && labels) ? labels : [];
%>
<% _labels.forEach(function (lbl) { %>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium leading-tight <%= _labelClasses[lbl] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200' %>"><%= lbl %></span>
<% }); %>
+16
View File
@@ -0,0 +1,16 @@
<%
// Multi-select label picker (checkboxes named "labels"). Expects locals:
// labelOptions array of all selectable labels
// selected array of currently selected labels (optional)
var _opts = (typeof labelOptions !== 'undefined' && labelOptions) ? labelOptions : [];
var _sel = (typeof selected !== 'undefined' && selected) ? selected : [];
%>
<div class="flex flex-wrap gap-2">
<% _opts.forEach(function (opt) { %>
<label class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border cursor-pointer text-sm transition-colors border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
<input type="checkbox" name="labels" value="<%= opt %>" <%= _sel.indexOf(opt) !== -1 ? 'checked' : '' %>
class="rounded border-gray-300 dark:border-gray-500 text-blue-600 focus:ring-blue-500">
<span class="text-gray-700 dark:text-gray-200"><%= opt %></span>
</label>
<% }); %>
</div>
+54
View File
@@ -0,0 +1,54 @@
<% if (!laeufe.length) { %>
<p class="text-sm text-gray-500 dark:text-gray-400">Noch kein Suchlauf durchgeführt.</p>
<% } else { %>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
<th class="py-2 pr-4 font-medium">Zeitpunkt</th>
<th class="py-2 pr-4 font-medium">Auslöser</th>
<th class="py-2 pr-4 font-medium">Status</th>
<th class="py-2 pr-4 font-medium">Ergebnis</th>
</tr>
</thead>
<tbody>
<% laeufe.forEach(function (l) {
var badge = {
angefordert: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200',
laeuft: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
fertig: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
fehler: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
}[l.status] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200';
var text = {
angefordert: 'wartet', laeuft: 'läuft…', fertig: 'fertig', fehler: 'Fehler'
}[l.status] || l.status;
var zeit = l.gestartet_at || l.angefordert_at;
%>
<tr class="border-b border-gray-100 dark:border-gray-700/60">
<td class="py-2 pr-4 text-gray-700 dark:text-gray-300 whitespace-nowrap">
<%= zeit ? new Date(zeit + 'Z').toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '' %>
</td>
<td class="py-2 pr-4 text-gray-500 dark:text-gray-400">
<%= l.ausloeser === 'zeitplan' ? 'Zeitplan' : 'manuell' %>
</td>
<td class="py-2 pr-4">
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium <%= badge %>"><%= text %></span>
</td>
<td class="py-2 pr-4 text-gray-700 dark:text-gray-300">
<% if (l.status === 'fertig') { %>
<span class="font-medium text-green-700 dark:text-green-400"><%= l.neu || 0 %> neu</span>,
<%= l.dubletten || 0 %> Dubletten, <%= l.verworfen || 0 %> verworfen
<% } else if (l.status === 'fehler') { %>
<span class="text-red-700 dark:text-red-400" title="<%= l.fehler || '' %>">
<%= (l.fehler || 'Unbekannter Fehler').slice(0, 80) %>
</span>
<% } else { %>
<span class="text-gray-400 dark:text-gray-500"></span>
<% } %>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<% } %>
+27 -22
View File
@@ -3,10 +3,10 @@
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<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', { hideSettings: true }) %>
<main class="container mx-auto px-4 py-8 max-w-4xl">
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
<!-- Back link -->
<a href="/" 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">
@@ -57,14 +57,20 @@
<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>
</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>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
<% if (e.display_srcdoc) { %>
<iframe class="email-html-frame w-full border border-gray-200 dark:border-gray-600 rounded bg-white" style="height:6rem"
sandbox="allow-same-origin" referrerpolicy="no-referrer"
srcdoc="<%= e.display_srcdoc %>"></iframe>
<% } else { %>
<p class="email-body whitespace-pre-wrap break-words text-sm leading-relaxed text-gray-700 dark:text-gray-300 max-h-40 overflow-hidden"><%= e.body_text || '' %></p>
<% if (e.body_text && e.body_text.length > 600) { %>
<button type="button" class="email-toggle text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 hover:underline mt-1"
data-expanded="false">Vollständig anzeigen</button>
<% } %>
<% } %>
<% if (e.anhaenge && e.anhaenge.length) { %>
<div class="mt-2 flex flex-wrap gap-2">
@@ -124,28 +130,27 @@
</script>
<script>
(function () {
const root = document.documentElement;
const dm = localStorage.getItem('darkMode');
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
const toggle = document.getElementById('darkModeToggle');
const sun = document.getElementById('sunIcon');
const moon = document.getElementById('moonIcon');
function sync() {
const d = root.classList.contains('dark');
if (sun) sun.classList.toggle('hidden', d);
if (moon) moon.classList.toggle('hidden', !d);
}
sync();
if (toggle) toggle.addEventListener('click', () => {
root.classList.toggle('dark');
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
sync();
});
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
// Size each sandboxed e-mail iframe to its rendered content. The
// sandbox omits allow-scripts, so mail scripts never run; allow-same-
// origin lets us measure the document height.
function fitFrame(f) {
try {
var doc = f.contentDocument || (f.contentWindow && f.contentWindow.document);
if (!doc || !doc.body) return;
var h = Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight);
if (h > 0) f.style.height = (h + 6) + 'px';
} catch (e) { /* leave default height */ }
}
document.querySelectorAll('.email-html-frame').forEach(function (f) {
f.addEventListener('load', function () { fitFrame(f); });
fitFrame(f);
setTimeout(function () { fitFrame(f); }, 400);
setTimeout(function () { fitFrame(f); }, 1500);
});
// Expand/collapse the full e-mail body for each card.
document.querySelectorAll('.email-toggle').forEach(function (btn) {
btn.addEventListener('click', function () {
+207
View File
@@ -0,0 +1,207 @@
<!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-4xl">
<!-- Back link -->
<a href="/" 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ück zur Übersicht
</a>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div class="flex items-start gap-3">
<svg class="w-6 h-6 text-gray-500 dark:text-gray-400 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Einstellungen</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-2xl">
Hier liegen alle Werte, die früher über die <code class="px-1 rounded bg-gray-100 dark:bg-gray-700">.env</code>-Datei
gesetzt wurden Ollama, E-Mail, Kalender und die REST-API. Gespeichert wird in der Datenbank;
Änderungen wirken <em>sofort</em>, ein Neustart ist nicht nötig. Lediglich die Abruf-/Sync-Intervalle
(E-Mail &amp; Kalender) greifen erst nach einem Neustart.
</p>
</div>
</div>
</div>
<form action="/einstellungen" method="POST" class="space-y-6">
<% felder.forEach(function(sektion){ %>
<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"><%= sektion.titel %></h3>
<% if (sektion.beschreibung) { %>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5 max-w-2xl"><%= sektion.beschreibung %></p>
<% } else { %>
<div class="mb-5"></div>
<% } %>
<div class="grid gap-4 sm:grid-cols-2">
<% sektion.items.forEach(function(f){
var val = werte[f.key] != null ? werte[f.key] : '';
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 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' : '' %>">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="cfg-<%= f.key %>">
<%= f.label %>
<code class="ml-1 text-[11px] font-normal text-gray-400 dark:text-gray-500"><%= f.key %></code>
</label>
<div class="relative">
<input id="cfg-<%= f.key %>" name="<%= f.key %>" type="<%= inputType %>" autocomplete="off"
spellcheck="false" class="<%= inputClass %>" value="<%= val %>">
<% if (f.secret) { %>
<button type="button" data-toggle="cfg-<%= f.key %>"
class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
title="Wert anzeigen/verbergen" tabindex="-1">
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
<% } %>
</div>
<% if (f.generate) { %>
<div class="flex items-center gap-2 mt-2">
<button type="button" data-generate="cfg-<%= f.key %>"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Neu generieren
</button>
<button type="button" data-copy="cfg-<%= f.key %>"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
Kopieren
</button>
<span data-hint="cfg-<%= f.key %>" class="text-xs text-amber-600 dark:text-amber-400 hidden"></span>
</div>
<% } %>
<% if (f.help) { %>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1"><%= f.help %></p>
<% } %>
</div>
<% }); %>
</div>
</section>
<% }); %>
<div class="flex items-center justify-end gap-3">
<a href="/einstellungen" class="px-4 py-2 text-sm 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">
Verwerfen
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
<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="M5 13l4 4L19 7"></path>
</svg>
Speichern
</button>
</div>
</form>
</main>
<%- include('partials/footer') %>
<script>
(function () {
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
// Toggle reveal for secret (password) fields.
document.querySelectorAll('[data-toggle]').forEach(function (btn) {
btn.addEventListener('click', function () {
const input = document.getElementById(btn.getAttribute('data-toggle'));
if (input) input.type = input.type === 'password' ? 'text' : 'password';
});
});
function hint(id, text, tone) {
const el = document.querySelector('[data-hint="' + id + '"]');
if (!el) return;
el.textContent = text;
el.classList.remove('hidden');
el.classList.toggle('text-amber-600', tone !== 'ok');
el.classList.toggle('dark:text-amber-400', tone !== 'ok');
el.classList.toggle('text-green-600', tone === 'ok');
el.classList.toggle('dark:text-green-400', tone === 'ok');
}
// Generate a fresh random token (32 bytes hex, from the browser CSPRNG).
// It only fills the field — nothing changes until the form is saved,
// so an accidental click can still be discarded.
document.querySelectorAll('[data-generate]').forEach(function (btn) {
btn.addEventListener('click', function () {
const id = btn.getAttribute('data-generate');
const input = document.getElementById(id);
if (!input) return;
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
input.value = Array.from(bytes).map(function (b) {
return b.toString(16).padStart(2, '0');
}).join('');
input.type = 'text'; // reveal, so it can be copied down
hint(id, 'Neuer Token erzeugt — zum Aktivieren speichern. Der alte wird damit ungültig.');
});
});
document.querySelectorAll('[data-copy]').forEach(function (btn) {
btn.addEventListener('click', async function () {
const id = btn.getAttribute('data-copy');
const input = document.getElementById(id);
if (!input || !input.value) return;
try {
await navigator.clipboard.writeText(input.value);
hint(id, 'In die Zwischenablage kopiert.', 'ok');
} catch (e) {
input.type = 'text';
input.select();
hint(id, 'Kopieren nicht möglich — Wert ist markiert, bitte manuell kopieren.');
}
});
});
})();
</script>
</body>
</html>
+357
View File
@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
<style>
/* ── Statistics page — scoped chart primitives ─────────────────────────
The cards themselves are plain Tailwind; these classes build the bars,
the trend and the funnel so the markup stays readable. Everything keys
off --stat-accent so a single declaration re-tints a row. */
:root { --stat-accent: #2563eb; }
.stat-empty {
padding: 2.5rem 1rem;
text-align: center;
color: rgb(100 116 139);
}
.dark .stat-empty { color: rgb(148 163 184); }
/* KPI tile */
.kpi {
position: relative;
overflow: hidden;
border-radius: 1rem;
background: #fff;
border: 1px solid rgb(226 232 240);
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
padding: 1.25rem 1.25rem 1.1rem;
transition: transform .18s ease, box-shadow .18s ease;
}
.kpi:hover { transform: translateY(-2px); box-shadow: 0 12px 24px -12px rgba(15,23,42,.18); }
.dark .kpi { background: rgb(30 41 59); border-color: rgba(255,255,255,.06); }
.kpi-ico {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: .75rem;
color: #fff; margin-bottom: .9rem;
}
.kpi-val { font-size: 1.875rem; font-weight: 700; letter-spacing: -.02em; line-height: 1.1; }
.kpi-lab { margin-top: .25rem; font-size: .8rem; color: rgb(100 116 139); }
.dark .kpi-lab { color: rgb(148 163 184); }
.kpi-sub { margin-top: .35rem; font-size: .72rem; font-weight: 500; }
.kpi-glow {
position: absolute; right: -1.5rem; top: -1.5rem;
width: 5rem; height: 5rem; border-radius: 9999px;
filter: blur(28px); opacity: .35;
}
/* Generic card */
.stat-card {
border-radius: 1rem;
background: #fff;
border: 1px solid rgb(226 232 240);
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
padding: 1.25rem 1.5rem 1.5rem;
}
.dark .stat-card { background: rgb(30 41 59); border-color: rgba(255,255,255,.06); }
.stat-card h2 { font-size: 1rem; font-weight: 600; }
.stat-card .sub { font-size: .78rem; color: rgb(100 116 139); margin-top: .15rem; }
.dark .stat-card .sub { color: rgb(148 163 184); }
/* Horizontal bars (Art / Status / Ort) */
.bar-row { display: grid; grid-template-columns: minmax(0,9rem) 1fr 2.5rem; align-items: center; gap: .75rem; padding: .35rem 0; }
.bar-label { font-size: .82rem; color: rgb(71 85 105); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dark .bar-label { color: rgb(203 213 225); }
.bar-track { position: relative; height: .6rem; border-radius: 9999px; background: rgb(241 245 249); overflow: hidden; }
.dark .bar-track { background: rgba(255,255,255,.07); }
.bar-fill { position: absolute; inset: 0 auto 0 0; border-radius: 9999px; background: var(--stat-accent); width: 0; animation: bar-grow .6s cubic-bezier(.2,.8,.2,1) forwards; }
.bar-value { font-size: .8rem; font-weight: 600; text-align: right; color: rgb(30 41 52); }
.dark .bar-value { color: #fff; }
@keyframes bar-grow { from { width: 0; } }
/* Status swatch */
.swatch { display: inline-block; width: .55rem; height: .55rem; border-radius: 9999px; margin-right: .4rem; background: var(--stat-accent); vertical-align: middle; }
/* Trend (monthly) — SVG fills its box, bars keep aspect via viewBox */
.trend-wrap { width: 100%; }
.trend-wrap svg { width: 100%; height: auto; display: block; }
.trend-bar { transition: opacity .15s ease; }
.trend-bar:hover { opacity: .85; }
.trend-axis { fill: rgb(148 163 184); font-size: 10px; }
.dark .trend-axis { fill: rgb(100 116 139); }
.trend-grid { stroke: rgb(226 232 240); stroke-width: 1; }
.dark .trend-grid { stroke: rgba(255,255,255,.07); }
/* Funnel */
.funnel-row { margin-bottom: .65rem; }
.funnel-top { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .25rem; }
.funnel-label { font-size: .82rem; font-weight: 500; color: rgb(51 65 85); }
.dark .funnel-label { color: rgb(203 213 225); }
.funnel-meta { font-size: .75rem; color: rgb(100 116 139); }
.dark .funnel-meta { color: rgb(148 163 184); }
.funnel-track { position: relative; height: 1.85rem; border-radius: .6rem; background: rgb(241 245 249); overflow: hidden; }
.dark .funnel-track { background: rgba(255,255,255,.06); }
.funnel-fill { position: absolute; inset: 0 auto 0 0; border-radius: .6rem; display: grid; place-items: end; padding-right: .65rem; font-size: .8rem; font-weight: 700; color: #fff; width: 0; animation: bar-grow .7s cubic-bezier(.2,.8,.2,1) forwards; }
.funnel-fill span { transform: translateY(-1px); }
/* Activity timeline */
.tl { position: relative; padding-left: 1.5rem; }
.tl::before { content: ''; position: absolute; left: .4rem; top: .35rem; bottom: .35rem; width: 2px; background: rgb(226 232 240); }
.dark .tl::before { background: rgba(255,255,255,.1); }
.tl-item { position: relative; padding: .35rem 0 .9rem; }
.tl-item::before { content: ''; position: absolute; left: -1.15rem; top: .55rem; width: .65rem; height: .65rem; border-radius: 9999px; background: var(--stat-accent); box-shadow: 0 0 0 3px #fff; }
.dark .tl-item::before { box-shadow: 0 0 0 3px rgb(30 41 59); }
@media (prefers-reduced-motion: reduce) {
.bar-fill, .funnel-fill { animation: none; width: var(--w); }
.kpi:hover { transform: none; }
}
</style>
</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">
<%- include('partials/header') %>
<main class="flex-1 container mx-auto px-4 py-8">
<!-- Page heading -->
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Statistik</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-slate-400">Auswertung all deiner Bewerbungen — Verlauf, Erfolgsquote und Verteilungen.</p>
</div>
<div class="flex items-center gap-2 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-white/5 px-4 py-2 text-sm shadow-sm">
<svg class="h-4 w-4 text-blue-600 dark:text-sky-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-3 3 1.34 3 3zm12-3c0 1.66-1.34 3-3 3s-3-1.34-3-3 1.34-3 3-3 3 1.34 3 3z"></path></svg>
<span class="font-medium text-gray-700 dark:text-gray-200"><%= total %> Bewerbungen</span>
</div>
</div>
<% if (total === 0) { %>
<div class="stat-card">
<div class="stat-empty">
<svg class="mx-auto mb-3 h-10 w-10 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13h2l3 9 4-16 3 9h6"></path></svg>
<p class="text-base font-medium text-gray-600 dark:text-gray-300">Noch keine Bewerbungen erfasst</p>
<p class="mt-1 text-sm">Sobald du Bewerbungen anlegst, erscheint hier deine komplette Auswertung.</p>
</div>
</div>
<% } else { %>
<!-- KPI cards -->
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-4 mb-8">
<div class="kpi">
<div class="kpi-glow" style="background:#2563eb"></div>
<div class="kpi-ico" style="background:linear-gradient(135deg,#3b82f6,#2563eb)">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" 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>
</div>
<div class="kpi-val text-gray-900 dark:text-white"><%= total %></div>
<div class="kpi-lab">Bewerbungen gesamt</div>
</div>
<div class="kpi">
<div class="kpi-glow" style="background:#0ea5e9"></div>
<div class="kpi-ico" style="background:linear-gradient(135deg,#38bdf8,#0ea5e9)">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
</div>
<div class="kpi-val text-gray-900 dark:text-white"><%= kpis.gesendet %></div>
<div class="kpi-lab">Versendet</div>
</div>
<div class="kpi">
<div class="kpi-glow" style="background:#10b981"></div>
<div class="kpi-ico" style="background:linear-gradient(135deg,#34d399,#10b981)">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
<div class="kpi-val text-emerald-600 dark:text-emerald-400"><%= kpis.eingestellt %></div>
<div class="kpi-lab">Einstellungen</div>
<div class="kpi-sub text-emerald-600/80 dark:text-emerald-400/80"><%= kpis.erfolgsquote %>% Erfolgsquote</div>
</div>
<div class="kpi">
<div class="kpi-glow" style="background:#8b5cf6"></div>
<div class="kpi-ico" style="background:linear-gradient(135deg,#a78bfa,#8b5cf6)">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-2a4 4 0 100-8 4 4 0 000 8z"></path></svg>
</div>
<div class="kpi-val text-violet-600 dark:text-violet-300"><%= kpis.gespraech %></div>
<div class="kpi-lab">Vorstellungsgespräche</div>
</div>
<div class="kpi">
<div class="kpi-glow" style="background:#f43f5e"></div>
<div class="kpi-ico" style="background:linear-gradient(135deg,#fb7185,#f43f5e)">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg>
</div>
<div class="kpi-val text-rose-600 dark:text-rose-400"><%= kpis.absagen %></div>
<div class="kpi-lab">Absagen</div>
<div class="kpi-sub text-gray-500 dark:text-slate-400"><%= kpis.antwortquote %>% Antwortquote</div>
</div>
<div class="kpi">
<div class="kpi-glow" style="background:#f59e0b"></div>
<div class="kpi-ico" style="background:linear-gradient(135deg,#fbbf24,#f59e0b)">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
</div>
<div class="kpi-val text-gray-900 dark:text-white"><%= kpis.avgProMonat %></div>
<div class="kpi-lab">Ø pro Monat</div>
<div class="kpi-sub text-gray-500 dark:text-slate-400">über <%= kpis.monateAktiv %> Monate</div>
</div>
</div>
<!-- Monthly trend -->
<div class="stat-card mb-8">
<h2 class="text-gray-900 dark:text-white">Bewerbungen pro Monat</h2>
<p class="sub mb-5">Wie viele Bewerbungen in jedem Monat erstellt wurden.</p>
<%
const maxM = Math.max(1, ...byMonth.map(m => m.count));
const VB = 1000, BH = 320; // viewBox dimensions
const padL = 28, padR = 10, padB = 26, padT = 10;
const innerW = VB - padL - padR;
const innerH = BH - padT - padB;
const slot = byMonth.length ? innerW / byMonth.length : innerW;
const bw = Math.min(slot * .62, 40);
const monthName = (ym) => { const [, mo] = ym.split('-'); return ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'][Number(mo)-1] || ''; };
const yearOf = (ym) => ym.split('-')[0];
let prevYear = '';
%>
<div class="trend-wrap">
<svg viewBox="0 0 <%= VB %> <%= BH %>" preserveAspectRatio="none" role="img" aria-label="Bewerbungen pro Monat">
<% for (let gy = 1; gy <= 4; gy++) { const y = padT + innerH * (1 - gy/4); const val = Math.round(maxM * gy/4); %>
<line class="trend-grid" x1="<%= padL %>" y1="<%= y %>" x2="<%= VB - padR %>" y2="<%= y %>"></line>
<text class="trend-axis" x="<%= padL-6 %>" y="<%= y+3 %>" text-anchor="end"><%= val %></text>
<% } %>
<% byMonth.forEach((m, i) => { const h = (m.count / maxM) * innerH; const x = padL + slot * i + (slot - bw)/2; const y = padT + innerH - h; const yr = yearOf(m.ym); const showYear = yr !== prevYear; prevYear = yr; %>
<rect class="trend-bar" x="<%= x %>" y="<%= y %>" width="<%= bw %>" height="<%= Math.max(h, 0.5) %>" rx="3" fill="url(#trendGrad)"></rect>
<text class="trend-axis" x="<%= x + bw/2 %>" y="<%= BH - 9 %>" text-anchor="middle"><%= monthName(m.ym) %></text>
<% if (showYear) { %><text class="trend-axis" x="<%= x + bw/2 %>" y="<%= BH - 1 %>" text-anchor="middle" style="font-size:8px;opacity:.7"><%= yr %></text><% } %>
<title><%= m.ym %>: <%= m.count %> Bewerbung<%= m.count===1?'':'en' %></title>
<% }); %>
<defs>
<linearGradient id="trendGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b82f6"></stop>
<stop offset="100%" stop-color="#0ea5e9"></stop>
</linearGradient>
</defs>
</svg>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- By Status -->
<div class="stat-card">
<h2 class="text-gray-900 dark:text-white">Verteilung nach Status</h2>
<p class="sub mb-4">Aktueller Status jeder Bewerbung.</p>
<% if (byStatus.length) {
const maxS = Math.max(...byStatus.map(s => s.count));
const statusColor = (s) => ({
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
'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',
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
}[s] || '#2563eb');
%>
<% byStatus.forEach(s => { const pct = Math.round((s.count / maxS) * 100); %>
<div class="bar-row">
<span class="bar-label"><span class="swatch" style="background:<%= statusColor(s.status) %>"></span><%= s.status %></span>
<div class="bar-track"><div class="bar-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= statusColor(s.status) %>"></div></div>
<span class="bar-value"><%= s.count %></span>
</div>
<% }); %>
<% } else { %><div class="stat-empty">Keine Daten</div><% } %>
</div>
<!-- By Art -->
<div class="stat-card">
<h2 class="text-gray-900 dark:text-white">Verteilung nach Bewerbungsart</h2>
<p class="sub mb-4">Über welchen Kanal die Bewerbung lief.</p>
<% if (byArt.length) { const maxA = Math.max(...byArt.map(a => a.count)); const artColor = ['#2563eb','#0ea5e9','#10b981','#8b5cf6','#f59e0b','#f43f5e','#6366f1','#ec4899','#14b8a6']; %>
<% byArt.forEach((a, i) => { const pct = Math.round((a.count / maxA) * 100); %>
<div class="bar-row">
<span class="bar-label"><%= a.art %></span>
<div class="bar-track"><div class="bar-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= artColor[i % artColor.length] %>"></div></div>
<span class="bar-value"><%= a.count %></span>
</div>
<% }); %>
<% } else { %><div class="stat-empty">Keine Daten</div><% } %>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Funnel -->
<div class="stat-card">
<h2 class="text-gray-900 dark:text-white">Bewerbungs-Funnel</h2>
<p class="sub mb-5">Wie viele Bewerbungen jede Stufe je erreicht haben.</p>
<% const topF = Math.max(1, funnel[0].count);
const fColor = ['#2563eb','#3b82f6','#0ea5e9','#10b981','#059669'];
funnel.forEach((f, i) => { const pct = Math.round((f.count / topF) * 100); const conv = i === 0 ? '' : ' · ' + Math.round((f.count / Math.max(1, funnel[0].count) * 100)) + '%'; %>
<div class="funnel-row">
<div class="funnel-top">
<span class="funnel-label"><%= f.label %></span>
<span class="funnel-meta"><%= f.count %><%= conv %></span>
</div>
<div class="funnel-track">
<div class="funnel-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= fColor[i] %>"><span><%= f.count %></span></div>
</div>
</div>
<% }); %>
</div>
<!-- By Ort -->
<div class="stat-card">
<h2 class="text-gray-900 dark:text-white">Top Standorte</h2>
<p class="sub mb-4">Wo du dich am häufigsten bewirbst.</p>
<% if (byOrt.length) { const maxO = Math.max(...byOrt.map(o => o.count)); const ortColor = ['#6366f1','#0ea5e9','#10b981','#f59e0b','#f43f5e','#8b5cf6','#14b8a6','#ec4899','#3b82f6','#a3a3a3']; %>
<% byOrt.forEach((o, i) => { const pct = Math.round((o.count / maxO) * 100); %>
<div class="bar-row" style="grid-template-columns:minmax(0,11rem) 1fr 2.5rem">
<span class="bar-label"><%= o.ort %></span>
<div class="bar-track"><div class="bar-fill" style="--w:<%= pct %>%;width:<%= pct %>%;background:<%= ortColor[i % ortColor.length] %>"></div></div>
<span class="bar-value"><%= o.count %></span>
</div>
<% }); %>
<% } else { %><div class="stat-empty">Keine Standorte erfasst</div><% } %>
</div>
</div>
<!-- Recent activity -->
<div class="stat-card">
<h2 id="aktivitaet" class="text-gray-900 dark:text-white">Letzte Aktivität</h2>
<p class="sub mb-5">
Die jüngsten Statusänderungen und E-Mails deiner Bewerbungen.
<a href="/aktivitaeten" class="text-blue-600 dark:text-sky-400 hover:underline">Alle</a>
</p>
<% if (aktivitaet.length) {
const statusColor = (s) => ({
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
'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',
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
}[s] || '#2563eb');
const emailColor = (a) => a.direction === 'out' ? '#0ea5e9' : '#3b82f6';
%>
<div class="tl">
<% aktivitaet.forEach(a => {
const accent = a.typ === 'email' ? emailColor(a) : statusColor(a.status);
let titel = '', titelSub = '';
if (a.bewerbung_id) { titel = a.firma || '(ohne Firma)'; titelSub = a.stelle || ''; }
else if (a.typ === 'email') { titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail'); }
else { titel = 'Gelöschte Bewerbung'; }
%>
<div class="tl-item" style="--stat-accent:<%= accent %>">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<% if (a.bewerbung_id) { %>
<a href="/bewerbung/<%= a.bewerbung_id %>" class="text-sm font-medium text-gray-900 dark:text-white hover:underline"><%= titel %></a>
<% if (titelSub) { %><span class="text-sm text-gray-500 dark:text-slate-400">· <%= titelSub %></span><% } %>
<% } else { %>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200"><%= titel %></span>
<% } %>
</div>
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-sm">
<span class="inline-flex items-center gap-1.5 font-medium" style="color:<%= accent %>">
<span class="swatch" style="margin:0"></span><%= a.status %>
</span>
<% if (a.datum) { %><span class="text-gray-400 dark:text-slate-500">· <%= a.datum %></span><% } %>
<% if (a.kommentar) { %><span class="text-gray-500 dark:text-slate-400 truncate">· <%= a.kommentar %></span><% } %>
</div>
</div>
<% }); %>
</div>
<% } else { %><div class="stat-empty">Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.</div><% } %>
</div>
<% } %>
</main>
</body>
</html>
+250 -25
View File
@@ -3,10 +3,10 @@
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<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', { hideSettings: true }) %>
<main class="container mx-auto px-4 py-8 max-w-3xl">
<main class="flex-1 container mx-auto px-4 py-8 max-w-3xl">
<!-- Back link -->
<a href="/" 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">
@@ -15,6 +15,14 @@
Zurück zur Übersicht
</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>
<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
@@ -32,14 +40,89 @@
</svg>
<div class="text-sm text-amber-800 dark:text-amber-200">
<p class="font-semibold">Kein API-Schlüssel konfiguriert</p>
<p>Setze die Umgebungsvariable <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">OLLAMA_API_KEY</code>
(z. B. in einer <code class="px-1 rounded bg-amber-100 dark:bg-amber-800">.env</code>-Datei),
damit Bewerbungsunterlagen automatisch generiert werden können.</p>
<p>Trage den Ollama-API-Schlüssel unter
<a href="/einstellungen" class="underline font-medium hover:no-underline">Einstellungen</a>
ein, damit Bewerbungsunterlagen automatisch generiert werden können.</p>
</div>
</div>
</div>
<% } %>
<!-- Persönliche Angaben (formerly the index-page settings modal) -->
<h2 id="persoenlich" class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2 scroll-mt-4">Persönliche Angaben</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Deine Kontaktdaten fließen in jede Bewerbung ein oben in Lebenslauf und Anschreiben
sowie in die Kontaktleiste. Die KI übernimmt sie unverändert und erfindet nichts mehr;
sie müssen dafür nicht länger in den Basis-Unterlagen stehen.
</p>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<form action="/vorlagen/persoenlich" method="POST" class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Vollständiger Name *
</label>
<input type="text" id="name" name="name" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="Max Mustermann" value="<%= settings.name || '' %>">
</div>
<div>
<label for="adresse" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Adresse
</label>
<textarea id="adresse" name="adresse" rows="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="Musterstraße 1, 12345 Musterstadt"><%= settings.adresse || '' %></textarea>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="email" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">E-Mail</label>
<input type="email" id="email" name="email"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="name@example.com" value="<%= settings.email || '' %>">
</div>
<div>
<label for="telefon" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Telefon</label>
<input type="text" id="telefon" name="telefon"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="z. B. 030 12345678" value="<%= settings.telefon || '' %>">
</div>
<div>
<label for="ort" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Wohnort</label>
<input type="text" id="ort" name="ort"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="z. B. Berlin" value="<%= settings.ort || '' %>">
</div>
<div>
<label for="webseite" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Webseite</label>
<input type="text" id="webseite" name="webseite"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="z. B. nextjobs.cc" value="<%= settings.webseite || '' %>">
</div>
<div>
<label for="geburtsdatum" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Geburtsdatum</label>
<input type="text" id="geburtsdatum" name="geburtsdatum"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="TT.MM.JJJJ" value="<%= settings.geburtsdatum || '' %>">
</div>
<div>
<label for="kundennummer" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Jobcenter-Kundennummer</label>
<input type="text" id="kundennummer" name="kundennummer"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
placeholder="z. B. 123456789" value="<%= settings.kundennummer || '' %>">
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
Speichern
</button>
</div>
</form>
</section>
<!-- Existing base documents -->
<section class="space-y-4 mb-8">
<% if (basisDokumente.length === 0) { %>
@@ -165,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">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<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
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>
@@ -251,34 +336,174 @@
</button>
</form>
</section>
<!-- Design of the generated PDFs -->
<h2 id="design" class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2 scroll-mt-4">Design der Unterlagen</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Aussehen von Anschreiben und Lebenslauf. <strong>Das Anschreiben übernimmt immer das Layout des
Lebenslaufs</strong>, damit beide Dokumente als ein Set ankommen. Der Lebenslauf passt in jedem Layout
garantiert auf eine Seite. Die Vorschau zeigt Musterinhalte mit deinen Kontaktdaten, Foto und deiner
Unterschrift, ohne dass die KI läuft.
</p>
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 px-4 py-3 mb-6 text-sm text-blue-900 dark:text-blue-200">
<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.
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>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<form action="/vorlagen/design" method="POST" class="space-y-4" id="designForm">
<div class="grid gap-4 sm:grid-cols-2">
<% designFelder.forEach(f => { %>
<div data-feld="<%= f.key %>" <%- f.nurLayout ? 'data-nur-layout="' + f.nurLayout + '"' : '' %>>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"><%= f.label %></label>
<select name="<%= f.key %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<% f.optionen.forEach(o => { %>
<option value="<%= o.key %>" <%= o.gewaehlt ? 'selected' : '' %>><%= o.label %></option>
<% }) %>
</select>
</div>
<% }) %>
</div>
<!-- The hidden field must come first: when the box is checked both
values are submitted and the *last* one wins. -->
<input type="hidden" name="foto_anzeigen" value="0">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="foto_anzeigen" value="1" <%= designFotoAn ? 'checked' : '' %>
class="rounded border-gray-300 dark:border-gray-600">
Bewerberfoto im Lebenslauf anzeigen
<% if (!hasFoto) { %>
<span class="text-xs text-gray-400 dark:text-gray-500">(noch kein Foto hinterlegt)</span>
<% } %>
</label>
<div class="flex flex-wrap items-center gap-3 border-t border-gray-200 dark:border-gray-700 pt-4">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
Speichern
</button>
<button type="button" id="vorschauAnschreiben"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Vorschau Anschreiben
</button>
<button type="button" id="vorschauLebenslauf"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Vorschau Lebenslauf
</button>
<% if (designAngepasst) { %>
<button type="submit" formaction="/vorlagen/design/reset" formnovalidate
onclick="return confirm('Design auf die Standardwerte zurücksetzen?');"
class="text-sm text-gray-600 dark:text-gray-400 hover:underline">
Auf Standard zurücksetzen
</button>
<% } %>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500">
Die Vorschau zeigt die aktuell <em>ausgewählten</em> Werte auch ungespeicherte.
</p>
</form>
</section>
<!-- Editable KI prompts -->
<h2 id="prompts" class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2 scroll-mt-4">KI-Prompts</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Hier steht, <em>wie</em> die KI schreibt: Rolle, Tonfall und Regeln für Unterlagen, Chat und E-Mails.
Du kannst jeden Text anpassen etwa um zu duzen, Formulierungen zu verbieten oder eine andere
Sprache zu verlangen. Der technische Aufbau (Aufbau des Lebenslaufs, Felder der E-Mail) ist fest
verdrahtet und wird davon nicht berührt, damit nichts kaputtgehen kann.
Mit <strong>Zurücksetzen</strong> holst du jederzeit den Standardtext zurück.
</p>
<section class="space-y-4 mb-8">
<% prompts.forEach(p => { %>
<details class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden" <%= p.angepasst ? 'open' : '' %>>
<summary class="cursor-pointer select-none px-6 py-4 flex items-center gap-3">
<span class="font-semibold text-gray-800 dark:text-white"><%= p.titel %></span>
<% if (p.angepasst) { %>
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300">angepasst</span>
<% } else { %>
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400">Standard</span>
<% } %>
</summary>
<div class="px-6 pb-6 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3"><%= p.beschreibung %></p>
<% if (p.platzhalter && p.platzhalter.length) { %>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Platzhalter:
<% p.platzhalter.forEach(ph => { %>
<code class="px-1 rounded bg-gray-100 dark:bg-gray-700">{{<%= ph %>}}</code>
<% }) %>
wird beim Generieren automatisch ersetzt (Name aus den Einstellungen).
</p>
<% } %>
<form action="/vorlagen/prompts/<%= p.key %>" method="POST" class="space-y-3">
<textarea name="inhalt" rows="9"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-xs leading-relaxed"><%= p.inhalt %></textarea>
<div class="flex items-center gap-3">
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
Speichern
</button>
<% if (p.angepasst) { %>
<button type="submit" formaction="/vorlagen/prompts/<%= p.key %>/reset"
formnovalidate
onclick="return confirm('Diesen Prompt auf den Standardtext zurücksetzen? Deine Änderungen gehen verloren.');"
class="text-sm text-gray-600 dark:text-gray-400 hover:underline">
Auf Standard zurücksetzen
</button>
<% } %>
</div>
</form>
</div>
</details>
<% }) %>
</section>
</main>
<%- include('partials/footer') %>
<script>
(function () {
const root = document.documentElement;
const dm = localStorage.getItem('darkMode');
if (dm === 'enabled' || (!dm && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
const toggle = document.getElementById('darkModeToggle');
const sun = document.getElementById('sunIcon');
const moon = document.getElementById('moonIcon');
function sync() {
const d = root.classList.contains('dark');
if (sun) sun.classList.toggle('hidden', d);
if (moon) moon.classList.toggle('hidden', !d);
}
sync();
if (toggle) toggle.addEventListener('click', () => {
root.classList.toggle('dark');
localStorage.setItem('darkMode', root.classList.contains('dark') ? 'enabled' : 'disabled');
sync();
});
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
})();
// Design preview: open the sample PDF with the values currently selected
// in the form (not just the saved ones), so a choice can be judged before
// it is committed.
(function () {
const form = document.getElementById('designForm');
if (!form) return;
function oeffne(welches) {
const q = new URLSearchParams(new FormData(form)).toString();
window.open('/vorlagen/design/vorschau/' + welches + '.pdf?' + q, '_blank');
}
const a = document.getElementById('vorschauAnschreiben');
const l = document.getElementById('vorschauLebenslauf');
if (a) a.addEventListener('click', () => oeffne('anschreiben'));
if (l) l.addEventListener('click', () => oeffne('lebenslauf'));
// Hide fields that only apply to one layout (e.g. the sidebar tint has
// no meaning in the social layout).
const layoutSel = form.querySelector('[name="layout"]');
function syncFelder() {
const aktiv = layoutSel ? layoutSel.value : 'sidebar';
form.querySelectorAll('[data-nur-layout]').forEach((el) => {
el.style.display = el.dataset.nurLayout === aktiv ? '' : 'none';
});
}
if (layoutSel) layoutSel.addEventListener('change', syncFelder);
syncFelder();
})();
</script>
</body>
</html>