From fd20ca0daf1d034f0efee1f52804518b9b9fef6b Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Thu, 2 Jul 2026 22:21:28 +0200 Subject: [PATCH] Add AI application assistant: Indeed import + Ollama document generation - Browser extension (Chromium MV3) injecting a "send to tracker" button next to the Indeed job description; scrapes job info and posts it to a new /api/indeed-import endpoint (CORS-enabled), configurable tracker URL via popup. - New "Entwurf" status. Imports create a draft and trigger background AI generation of tailored Anschreiben + Lebenslauf (PDF attachments) via the Ollama Cloud API, grounded strictly in user-provided base documents. - Vorlagen page to manage base documents; attachments UI, generation status polling, regenerate and download routes on the application page. - Schema: ort/stellenbeschreibung/quelle_url/generierung_* columns, plus basis_dokumente and anhaenge tables (with migrations). - Config via .env (OLLAMA_API_KEY/OLLAMA_MODEL/OLLAMA_HOST); dependency-free .env loader. Dockerfile copies lib/, .dockerignore added. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 11 ++ .env.example | 11 ++ Dockerfile | 1 + README.md | 52 ++++++ extension/README.md | 50 ++++++ extension/background.js | 56 +++++++ extension/content.css | 48 ++++++ extension/content.js | 212 ++++++++++++++++++++++++ extension/manifest.json | 29 ++++ extension/popup.html | 52 ++++++ extension/popup.js | 41 +++++ lib/documents.js | 259 +++++++++++++++++++++++++++++ public/js/main.js | 3 +- server.js | 338 +++++++++++++++++++++++++++++++++++++- views/bewerbung.ejs | 126 ++++++++++++++ views/index.ejs | 4 +- views/partials/header.ejs | 12 +- views/vorlagen.ejs | 155 +++++++++++++++++ 18 files changed, 1453 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 extension/README.md create mode 100644 extension/background.js create mode 100644 extension/content.css create mode 100644 extension/content.js create mode 100644 extension/manifest.json create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 lib/documents.js create mode 100644 views/vorlagen.ejs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..829d2d8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +data +extension +indeed_example.txt +.env +.env.* +*.log +.git +.idea +.vscode +npm-debug.log* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..92df284 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Kopiere diese Datei nach .env und trage deinen Schlüssel ein. +# .env wird NICHT eingecheckt (siehe .gitignore). + +# 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 – anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434) +# OLLAMA_HOST=https://ollama.com diff --git a/Dockerfile b/Dockerfile index e010d8e..1167bb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,7 @@ COPY --from=builder /app/node_modules ./node_modules # Application source COPY package.json ./ COPY server.js ./ +COPY lib ./lib COPY views ./views COPY public ./public diff --git a/README.md b/README.md index 0d9065b..67b0ed9 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,47 @@ Ein professioneller Job Application Tracker für Jobcenter Grundsicherung Monats - **Filterfunktion**: Filterung nach Monat und Jahr - **Statistiken**: Übersicht über Gesamtbewerbungen, nach Art und Status - **PDF-Export**: Professionelle PDF-Generierung für Jobcenter-Berichte +- **KI-Bewerbungsassistent**: Indeed-Stellen per Browser-Erweiterung importieren, + Status „Entwurf", und automatisch zugeschnittene Bewerbungsunterlagen (Anschreiben + & Lebenslauf als PDF) auf Basis eigener Vorlagen erstellen lassen - **Responsive Design**: Optimiert für Desktop und Mobile Geräte +## KI-Bewerbungsassistent (Indeed → Entwurf mit Unterlagen) + +Ablauf: + +1. **Vorlagen hinterlegen** – Unter **Vorlagen** (Kopfzeile) das eigene + 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. +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 + +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`: + +```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 +``` + +Alternativ als Umgebungsvariable: `export OLLAMA_API_KEY=...`. + +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 +**„Neu generieren"** wiederholt werden. + +Details zur Erweiterung: siehe [`extension/README.md`](extension/README.md). + ## Technologien - **Backend**: Node.js + Express.js @@ -152,6 +191,19 @@ Bewerbung aktualisieren ### DELETE /api/bewerbungen/:id Bewerbung löschen +### POST /api/indeed-import +Stelle importieren (von der Browser-Erweiterung genutzt): legt einen Entwurf an und +startet die KI-Generierung der Unterlagen. CORS ist für diesen Endpunkt aktiviert. + +### GET /api/bewerbungen/:id/generierung +Generierungsstatus und aktuelle Anhänge einer Bewerbung abfragen (Polling). + +### GET /api/basis-dokumente +Hinterlegte Basis-Unterlagen (Vorlagen) abrufen + +### GET /anhaenge/:id/download +Generierten Anhang (PDF) herunterladen + ### GET /api/bewerbungen/filter Bewerbungen mit Filter abrufen diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..c68d35c --- /dev/null +++ b/extension/README.md @@ -0,0 +1,50 @@ +# Bewerbungs-Tracker – Indeed 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). + +## 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. + +## 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. + +## 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. + +## Ausgelesene Felder + +| 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 | + +Indeed ist eine Single-Page-App; die Erweiterung erkennt Wechsel der geöffneten +Stelle per `MutationObserver` und hält den Button aktuell. diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..49a7041 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,56 @@ +// 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. + +const DEFAULT_TRACKER_URL = 'http://localhost:3000'; + +function getTrackerUrl() { + 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.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', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message.payload), + }); + + let data = {}; + try { data = await res.json(); } catch (_) { /* non-JSON response */ } + + if (!res.ok) { + sendResponse({ + ok: false, + error: (data && data.error) || `Server antwortete mit ${res.status}`, + }); + return; + } + + sendResponse({ + ok: true, + id: data.id, + message: data.message, + openUrl: data.url ? base + 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) + ')', + }); + } + })(); + return true; // keep the message channel open for the async response + } +}); diff --git a/extension/content.css b/extension/content.css new file mode 100644 index 0000000..7a8f937 --- /dev/null +++ b/extension/content.css @@ -0,0 +1,48 @@ +/* Button injected next to the Indeed job description */ +.bt-import-wrap { + margin: 12px 0; + display: flex; + flex-direction: column; + gap: 6px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +.bt-import-btn { + display: inline-flex; + align-items: center; + gap: 8px; + align-self: flex-start; + background: #16a34a; + color: #fff; + border: none; + border-radius: 8px; + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + transition: background 0.15s ease, opacity 0.15s ease; +} + +.bt-import-btn:hover { background: #15803d; } +.bt-import-btn:disabled { opacity: 0.7; cursor: default; } + +.bt-import-btn svg { width: 18px; height: 18px; flex: 0 0 auto; } + +.bt-import-status { + font-size: 13px; + line-height: 1.4; +} +.bt-import-status a { color: #2563eb; text-decoration: underline; } +.bt-import-status.bt-ok { color: #16a34a; } +.bt-import-status.bt-err { color: #dc2626; } + +/* Spinner */ +.bt-spin { + width: 16px; height: 16px; + border: 2px solid rgba(255, 255, 255, 0.5); + border-top-color: #fff; + border-radius: 50%; + animation: bt-rotate 0.7s linear infinite; +} +@keyframes bt-rotate { to { transform: rotate(360deg); } } diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..512efae --- /dev/null +++ b/extension/content.js @@ -0,0 +1,212 @@ +// Content script: injects an "An Bewerbungs-Tracker 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). + +(function () { + 'use strict'; + + const BTN_ID = 'bt-import-container'; + + // ----- Scraping --------------------------------------------------------- + + function cleanText(el) { + if (!el) return ''; + return (el.innerText || el.textContent || '').replace(/\s+\n/g, '\n').trim(); + } + + // The visible detail pane (right side on search pages, whole page on /viewjob). + function getDetailRoot() { + return ( + document.querySelector('.jobsearch-JobComponent') || + document.querySelector('#jobsearch-ViewjobPaneWrapper') || + document.querySelector('.jobsearch-RightPane') || + document + ); + } + + function getTitle(root) { + const el = + root.querySelector('[data-testid="jobsearch-JobInfoHeader-title"]') || + root.querySelector('.jobsearch-JobInfoHeader-title'); + if (!el) return ''; + // Drop the trailing "- job post" / "- Stellenanzeige" label Indeed appends. + let text = cleanText(el); + text = text.replace(/\s*-\s*(job post|Stellenanzeige)\s*$/i, '').trim(); + return text; + } + + function getCompany(root) { + const container = + root.querySelector('[data-testid="jobsearch-CompanyInfoContainer"]') || root; + const el = + container.querySelector('[data-testid="inlineHeader-companyName"]') || + container.querySelector('[data-company-name="true"]') || + container.querySelector('[data-testid="company-name"]'); + return cleanText(el); + } + + function getLocation(root) { + const el = + root.querySelector('[data-testid="jobsearch-JobInfoHeader-companyLocation"]') || + root.querySelector('[data-testid="inlineHeader-companyLocation"]'); + return cleanText(el); + } + + function getSalary(root) { + const el = + root.querySelector('#salaryInfoAndJobType') || + root.querySelector('[data-testid="jobsearch-OtherJobDetailsContainer"]'); + return cleanText(el); + } + + function getDescription(root) { + const el = root.querySelector('#jobDescriptionText'); + return cleanText(el); + } + + // Build a stable job URL. On the search page the selected job key lives in the + // `vjk` query param; a clean /viewjob URL is nicer than the search URL. + function getSourceUrl() { + try { + const u = new URL(window.location.href); + const vjk = u.searchParams.get('vjk') || u.searchParams.get('jk'); + if (vjk) return `${u.origin}/viewjob?jk=${vjk}`; + } catch (_) { /* ignore */ } + return window.location.href; + } + + function scrapeJob() { + const root = getDetailRoot(); + return { + firma: getCompany(root), + stelle: getTitle(root), + ort: getLocation(root), + gehalt: getSalary(root), + stellenbeschreibung: getDescription(root), + quelle_url: getSourceUrl(), + }; + } + + // ----- Button UI -------------------------------------------------------- + + const ICON_SEND = + '' + + ''; + + function buildContainer() { + const wrap = document.createElement('div'); + wrap.className = 'bt-import-wrap'; + wrap.id = BTN_ID; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'bt-import-btn'; + btn.innerHTML = ICON_SEND + 'An Bewerbungs-Tracker senden'; + + const status = document.createElement('div'); + status.className = 'bt-import-status'; + + btn.addEventListener('click', () => onClick(btn, status)); + + wrap.appendChild(btn); + wrap.appendChild(status); + return wrap; + } + + function setStatus(status, html, cls) { + status.className = 'bt-import-status' + (cls ? ' ' + cls : ''); + status.innerHTML = html; + } + + function onClick(btn, status) { + const job = scrapeJob(); + + if (!job.firma || !job.stelle) { + setStatus(status, 'Konnte Firma oder Stelle nicht auslesen. Bitte eine Stelle öffnen.', 'bt-err'); + return; + } + + btn.disabled = true; + const label = btn.querySelector('span'); + const originalLabel = label ? label.textContent : ''; + btn.innerHTML = 'Wird gesendet…'; + setStatus(status, '', ''); + + chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: job }, (resp) => { + btn.innerHTML = ICON_SEND + '' + (originalLabel || 'An Bewerbungs-Tracker senden') + ''; + + if (chrome.runtime.lastError) { + btn.disabled = false; + setStatus(status, 'Fehler: ' + chrome.runtime.lastError.message, 'bt-err'); + return; + } + if (!resp || !resp.ok) { + btn.disabled = false; + setStatus(status, (resp && resp.error) || 'Unbekannter Fehler.', 'bt-err'); + return; + } + + const link = resp.openUrl + ? ` Entwurf öffnen` + : ''; + setStatus( + status, + '✓ ' + (resp.message || 'Als Entwurf angelegt. Unterlagen werden erstellt.') + link, + 'bt-ok' + ); + // Re-enable after a moment so the user can send again if needed. + setTimeout(() => { btn.disabled = false; }, 1500); + }); + } + + // ----- Injection + SPA handling ----------------------------------------- + + // Insert the button just above the job description text, so it sits right + // next to the "Vollständige Stellenbeschreibung" heading. + function findAnchor() { + const heading = document.querySelector('#jobDescriptionTitleHeading'); + if (heading) return { node: heading, position: 'after' }; + const desc = document.querySelector('#jobDescriptionText'); + if (desc) return { node: desc, position: 'before' }; + const titleContainer = document.querySelector('.jobsearch-JobInfoHeader-title-container'); + if (titleContainer) return { node: titleContainer, position: 'after' }; + return null; + } + + function inject() { + const anchor = findAnchor(); + if (!anchor) return; + + const existing = document.getElementById(BTN_ID); + // Re-inject if missing or detached from the current anchor's parent. + if (existing && existing.parentNode === anchor.node.parentNode) return; + if (existing) existing.remove(); + + const container = buildContainer(); + if (anchor.position === 'after') { + anchor.node.parentNode.insertBefore(container, anchor.node.nextSibling); + } else { + anchor.node.parentNode.insertBefore(container, anchor.node); + } + } + + // Indeed is a single-page app: the detail pane swaps without a full reload. + // Observe DOM changes and (debounced) re-inject / keep the button in place. + let debounce = null; + const observer = new MutationObserver(() => { + if (debounce) clearTimeout(debounce); + debounce = setTimeout(inject, 300); + }); + + function start() { + inject(); + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.body) { + start(); + } else { + document.addEventListener('DOMContentLoaded', start); + } +})(); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..6dabded --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,29 @@ +{ + "manifest_version": 3, + "name": "Bewerbungs-Tracker – Indeed Import", + "version": "1.0.0", + "description": "Fügt auf Indeed einen Button neben der Stellenbeschreibung ein und sendet die Stelle an den Bewerbungs-Tracker, der automatisch zugeschnittene Bewerbungsunterlagen erstellt.", + "permissions": ["storage"], + "host_permissions": [ + "*://*.indeed.com/*", + "http://localhost/*", + "http://127.0.0.1/*", + "http://*/*", + "https://*/*" + ], + "content_scripts": [ + { + "matches": ["*://*.indeed.com/*"], + "js": ["content.js"], + "css": ["content.css"], + "run_at": "document_idle" + } + ], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_title": "Bewerbungs-Tracker" + } +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..26be571 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,52 @@ + + + + + + + +

Bewerbungs-Tracker

+

Adresse deines Bewerbungs-Trackers. Von hier werden importierte Indeed-Stellen verarbeitet.

+ + + + +
+ + +
+ +
+ + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..56c3b33 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,41 @@ +const DEFAULT_TRACKER_URL = 'http://localhost:3000'; + +const input = document.getElementById('trackerUrl'); +const statusEl = document.getElementById('status'); + +function normalize(url) { + return (url || '').trim().replace(/\/+$/, ''); +} + +function setStatus(text, cls) { + statusEl.textContent = text; + statusEl.className = cls || ''; +} + +// Load stored value +chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => { + input.value = items.trackerUrl || DEFAULT_TRACKER_URL; +}); + +document.getElementById('saveBtn').addEventListener('click', () => { + const url = normalize(input.value) || DEFAULT_TRACKER_URL; + chrome.storage.sync.set({ trackerUrl: url }, () => { + input.value = url; + setStatus('Gespeichert.', 'ok'); + }); +}); + +document.getElementById('testBtn').addEventListener('click', async () => { + const url = normalize(input.value) || DEFAULT_TRACKER_URL; + setStatus('Teste Verbindung…', ''); + try { + const res = await fetch(url + '/api/settings', { method: 'GET' }); + if (res.ok) { + setStatus('Verbindung erfolgreich ✓', 'ok'); + } else { + setStatus('Erreichbar, aber unerwartete Antwort (' + res.status + ').', 'err'); + } + } catch (err) { + setStatus('Nicht erreichbar: ' + err.message, 'err'); + } +}); diff --git a/lib/documents.js b/lib/documents.js new file mode 100644 index 0000000..e788406 --- /dev/null +++ b/lib/documents.js @@ -0,0 +1,259 @@ +// Document generation: tailors application documents to a job description with +// the help of an LLM (Ollama Cloud), then renders them to PDF. +// +// The AI *rewrites* the user's own, previously provided base documents +// (Basis-Dokumente) so the result stays grounded in real facts — it must not +// invent experience the applicant doesn't have. + +const { jsPDF } = require('jspdf'); + +// Ollama Cloud API (https://ollama.com). Override host/model via env if needed +// (e.g. point OLLAMA_HOST at a local Ollama instance). +const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, ''); +const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b'; +const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 180000); + +// ----- Ollama call --------------------------------------------------------- + +// Returns { anschreiben: string, lebenslauf: string }. +// Throws if no API key is configured or the API call fails. +async function generateTailoredTexts({ job, basisDokumente, settings }) { + const apiKey = process.env.OLLAMA_API_KEY; + if (!apiKey) { + throw new Error( + 'OLLAMA_API_KEY ist nicht gesetzt. Bitte den API-Schlüssel als ' + + 'Umgebungsvariable (z. B. in einer .env-Datei) hinterlegen, damit ' + + 'Bewerbungsunterlagen generiert werden können.' + ); + } + if (!basisDokumente || basisDokumente.length === 0) { + throw new Error( + 'Es sind keine Basis-Unterlagen hinterlegt. Bitte zuerst unter "Vorlagen" ' + + 'mindestens ein Basis-Dokument (z. B. Anschreiben und Lebenslauf) bereitstellen.' + ); + } + + const bewerber = [ + settings && settings.name ? `Name: ${settings.name}` : null, + settings && settings.adresse ? `Adresse: ${settings.adresse}` : null, + ].filter(Boolean).join('\n') || 'Keine Angaben'; + + const basisText = basisDokumente + .map((d, i) => `### Basis-Dokument ${i + 1} — Typ: ${d.typ || 'Sonstiges'} (${d.name || 'ohne Titel'})\n${d.inhalt}`) + .join('\n\n'); + + const stelleText = [ + job.stelle ? `Stellenbezeichnung: ${job.stelle}` : null, + job.firma ? `Unternehmen: ${job.firma}` : null, + job.ort ? `Ort: ${job.ort}` : null, + job.gehalt ? `Gehalt/Konditionen: ${job.gehalt}` : null, + job.quelle_url ? `Quelle: ${job.quelle_url}` : null, + '', + 'Stellenbeschreibung:', + job.stellenbeschreibung || '(keine Beschreibung übermittelt)', + ].filter((l) => l !== null).join('\n'); + + const system = + 'Du bist ein erfahrener Bewerbungscoach und schreibst professionelle, ' + + 'deutschsprachige Bewerbungsunterlagen. Du passt die BEREITGESTELLTEN ' + + 'Basis-Unterlagen des Bewerbers auf eine konkrete Stellenausschreibung an. ' + + 'Wichtigste Regel: Du erfindest KEINE Fakten, Qualifikationen, Abschlüsse ' + + 'oder Berufserfahrungen. Verwende ausschließlich Informationen, die in den ' + + 'Basis-Unterlagen des Bewerbers stehen. Du darfst umformulieren, ' + + 'gewichten, relevante Punkte hervorheben und auf die Stelle zuschneiden — ' + + 'aber nichts hinzudichten. Schreibe natürlich, überzeugend und ohne Floskeln.'; + + const userPrompt = + `# Bewerberdaten\n${bewerber}\n\n` + + `# Basis-Unterlagen des Bewerbers (Faktengrundlage — NUR diese Fakten verwenden)\n${basisText}\n\n` + + `# Zielstelle\n${stelleText}\n\n` + + `# Aufgabe\n` + + `Erstelle zwei zugeschnittene Dokumente:\n` + + `1. "anschreiben": Ein vollständiges, individuelles Anschreiben für genau diese Stelle. ` + + `Beginne mit einer passenden Anrede und schließe mit einer Grußformel und dem Namen des Bewerbers. ` + + `Betone die zur Stellenbeschreibung passenden Stärken aus den Basis-Unterlagen. ` + + `Keine Platzhalter wie [Firma] — nutze die echten Angaben. Kein Briefkopf/Adressblock, nur der Brieftext.\n` + + `2. "lebenslauf": Eine auf die Stelle zugeschnittene, gut strukturierte Fassung des Lebenslaufs ` + + `(relevante Stationen und Kompetenzen zuerst), rein auf Basis der vorhandenen Angaben. ` + + `Falls keine Lebenslauf-Informationen in den Basis-Unterlagen vorhanden sind, gib einen leeren String zurück.\n` + + `Antworte ausschließlich im geforderten JSON-Format.`; + + // Structured output: ask Ollama to return exactly this JSON shape. + const format = { + type: 'object', + properties: { + anschreiben: { type: 'string' }, + lebenslauf: { type: 'string' }, + }, + required: ['anschreiben', 'lebenslauf'], + }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS); + + let res; + try { + res = await fetch(`${OLLAMA_HOST}/api/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: OLLAMA_MODEL, + stream: false, + format, + options: { temperature: 0.4 }, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: userPrompt }, + ], + }), + signal: controller.signal, + }); + } catch (err) { + if (err.name === 'AbortError') { + throw new Error(`Zeitüberschreitung bei der KI-Anfrage (> ${Math.round(OLLAMA_TIMEOUT_MS / 1000)}s).`); + } + throw new Error(`Verbindung zur Ollama-API fehlgeschlagen: ${err.message}`); + } finally { + clearTimeout(timeout); + } + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Ollama-API antwortete mit ${res.status}: ${body.slice(0, 300)}`); + } + + const data = await res.json(); + const text = ((data && data.message && data.message.content) || '').trim(); + if (!text) { + throw new Error('Die KI hat keine Antwort geliefert (leerer Inhalt).'); + } + + let parsed; + try { + parsed = JSON.parse(text); + } catch (e) { + // Fallback: try to extract the first JSON object; otherwise treat the + // whole answer as the cover letter so nothing is lost. + const match = text.match(/\{[\s\S]*\}/); + if (match) { + try { parsed = JSON.parse(match[0]); } catch (_) { /* ignore */ } + } + if (!parsed) parsed = { anschreiben: text, lebenslauf: '' }; + } + + return { + anschreiben: (parsed.anschreiben || '').trim(), + lebenslauf: (parsed.lebenslauf || '').trim(), + }; +} + +// ----- PDF rendering ------------------------------------------------------- + +// Renders a text document to a PDF Buffer with a simple, clean A4 layout. +function textToPdf({ title, headerLines = [], bodyText = '' }) { + const doc = new jsPDF({ unit: 'mm', format: 'a4' }); + const pageWidth = doc.internal.pageSize.getWidth(); + const pageHeight = doc.internal.pageSize.getHeight(); + const marginX = 20; + const marginTop = 20; + const marginBottom = 20; + const maxWidth = pageWidth - marginX * 2; + let y = marginTop; + + function ensureSpace(lineHeight) { + if (y + lineHeight > pageHeight - marginBottom) { + doc.addPage(); + y = marginTop; + } + } + + function writeBlock(text, { fontSize = 11, fontStyle = 'normal', gapAfter = 4, lineHeight } = {}) { + doc.setFont('helvetica', fontStyle); + doc.setFontSize(fontSize); + const lh = lineHeight || fontSize * 0.5; // mm per line + // Preserve intentional blank lines from the source text. + const paragraphs = String(text).split('\n'); + paragraphs.forEach((para) => { + if (para.trim() === '') { + ensureSpace(lh); + y += lh; + return; + } + const lines = doc.splitTextToSize(para, maxWidth); + lines.forEach((line) => { + ensureSpace(lh); + doc.text(line, marginX, y); + y += lh; + }); + }); + y += gapAfter; + } + + if (title) { + writeBlock(title, { fontSize: 16, fontStyle: 'bold', gapAfter: 6 }); + } + if (headerLines.length) { + writeBlock(headerLines.join('\n'), { fontSize: 10, fontStyle: 'normal', gapAfter: 8 }); + } + if (bodyText) { + writeBlock(bodyText, { fontSize: 11, fontStyle: 'normal', gapAfter: 4, lineHeight: 6 }); + } + + const arrayBuffer = doc.output('arraybuffer'); + return Buffer.from(arrayBuffer); +} + +// Build the list of attachment documents (PDF buffers) for a job. +// Returns [{ name, filename, mime, buffer }]. +async function generateApplicationDocuments({ job, basisDokumente, settings }) { + const { anschreiben, lebenslauf } = await generateTailoredTexts({ job, basisDokumente, settings }); + + const today = new Date().toLocaleDateString('de-DE'); + const bewerberName = (settings && settings.name) || ''; + const bewerberAdresse = (settings && settings.adresse) || ''; + const safe = (s) => String(s || '').replace(/[^a-zA-Z0-9äöüÄÖÜß _-]/g, '').replace(/\s+/g, '_').slice(0, 60) || 'Bewerbung'; + const suffix = safe(job.firma || job.stelle || 'Bewerbung'); + + const documents = []; + + if (anschreiben) { + const headerLines = [ + bewerberName, + bewerberAdresse, + '', + job.firma || '', + job.ort || '', + '', + today, + '', + `Bewerbung als ${job.stelle || ''}`.trim(), + ].filter((l) => l !== null); + documents.push({ + name: `Anschreiben – ${job.stelle || job.firma || ''}`.trim(), + filename: `Anschreiben_${suffix}.pdf`, + mime: 'application/pdf', + buffer: textToPdf({ title: '', headerLines, bodyText: anschreiben }), + }); + } + + if (lebenslauf) { + const headerLines = [bewerberName, bewerberAdresse].filter(Boolean); + documents.push({ + name: `Lebenslauf – ${job.stelle || job.firma || ''}`.trim(), + filename: `Lebenslauf_${suffix}.pdf`, + mime: 'application/pdf', + buffer: textToPdf({ title: 'Lebenslauf', headerLines, bodyText: lebenslauf }), + }); + } + + if (documents.length === 0) { + throw new Error('Die KI hat keine verwertbaren Unterlagen erzeugt.'); + } + + return documents; +} + +module.exports = { generateApplicationDocuments, generateTailoredTexts, textToPdf }; diff --git a/public/js/main.js b/public/js/main.js index 747a63b..58ce97a 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -353,10 +353,11 @@ function generatePdfDocument(settings, applications, month, year) { yPos += 6; // ---- Group entries by status (order kept in sync with views/index.ejs) ---- - const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch', + const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch', 'Einstellung', 'Absage', '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], 'Vorstellungsgespräch': [245, 158, 11], diff --git a/server.js b/server.js index 5ad644c..775af91 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,31 @@ const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const fs = require('fs'); +// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local +// (git-ignored) .env file into process.env without overwriting existing vars. +(function loadEnv() { + try { + const envPath = path.join(__dirname, '.env'); + if (!fs.existsSync(envPath)) return; + for (const raw of fs.readFileSync(envPath, 'utf8').split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + let val = line.slice(eq + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + if (key && !(key in process.env)) process.env[key] = val; + } + } catch (e) { + console.warn('Konnte .env nicht laden:', e.message); + } +})(); + +const { generateApplicationDocuments } = require('./lib/documents'); + const app = express(); const PORT = process.env.PORT || 3000; @@ -13,15 +38,27 @@ const ART_OPTIONS = [ 'Arbeitsagentur', 'Sonstiges' ]; const STATUS_OPTIONS = [ - 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch', + 'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch', 'Absage', 'Einstellung', 'Keine Rückmeldung' ]; +// Base document types the user can provide as a foundation for AI tailoring +const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges']; // Middleware -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +app.use(express.json({ limit: '2mb' })); +app.use(express.urlencoded({ extended: true, limit: '2mb' })); app.use(express.static(path.join(__dirname, 'public'))); +// Allow the browser extension (running on indeed.com) to call the import API. +// Kept narrow: only the extension-facing endpoints need cross-origin access. +app.use('/api/indeed-import', (req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') return res.sendStatus(204); + next(); +}); + // Set EJS as template engine app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); @@ -32,6 +69,12 @@ if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } +// Directory for generated attachment files (application documents) +const anhaengeDir = path.join(dataDir, 'anhaenge'); +if (!fs.existsSync(anhaengeDir)) { + fs.mkdirSync(anhaengeDir, { recursive: true }); +} + // Database setup const dbPath = path.join(dataDir, 'bewerbungen.db'); const db = new sqlite3.Database(dbPath); @@ -96,6 +139,51 @@ async function attachVerlauf(applications) { return applications; } +// Run the AI document generation for one application (async, fire-and-forget). +// Loads the base documents + user settings, asks the LLM to tailor them to the +// job, writes the resulting PDFs to disk and links them as attachments. +async function runGeneration(bewerbungId) { + try { + const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]); + if (!bewerbung) return; + const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC'); + const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); + + const documents = await generateApplicationDocuments({ + job: { + firma: bewerbung.firma, + stelle: bewerbung.stelle, + ort: bewerbung.ort, + quelle_url: bewerbung.quelle_url, + stellenbeschreibung: bewerbung.stellenbeschreibung, + }, + basisDokumente, + settings, + }); + + for (const doc of documents) { + const stored = `${bewerbungId}_${Date.now()}_${doc.filename}`; + fs.writeFileSync(path.join(anhaengeDir, stored), doc.buffer); + await dbRun( + 'INSERT INTO anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)', + [bewerbungId, doc.name, doc.filename, doc.mime, stored] + ); + } + + await dbRun( + "UPDATE bewerbungen SET generierung_status = 'fertig', generierung_fehler = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [bewerbungId] + ); + console.log(`Bewerbungsunterlagen für #${bewerbungId} generiert (${documents.length} Dokument(e)).`); + } catch (error) { + console.error(`Generierung für #${bewerbungId} fehlgeschlagen:`, error.message); + await dbRun( + "UPDATE bewerbungen SET generierung_status = 'fehler', generierung_fehler = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [String(error.message || 'Unbekannter Fehler'), bewerbungId] + ).catch(() => {}); + } +} + // Initialize database - create tables and default settings in one operation function initializeDatabase() { return new Promise((resolve, reject) => { @@ -119,8 +207,13 @@ function initializeDatabase() { `, (err) => { if (err) return reject(err); - // Migration: add interne_notizen to pre-existing databases (ignore "duplicate column") + // Migration: add columns to pre-existing databases (ignore "duplicate column") db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => { + db.run('ALTER TABLE bewerbungen ADD COLUMN ort TEXT', () => { + db.run('ALTER TABLE bewerbungen ADD COLUMN stellenbeschreibung TEXT', () => { + db.run('ALTER TABLE bewerbungen ADD COLUMN quelle_url TEXT', () => { + db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status TEXT', () => { + db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_fehler TEXT', () => { // Chronological status changes, each with an optional comment db.run(` @@ -136,6 +229,33 @@ function initializeDatabase() { `, (err) => { if (err) return reject(err); + // Base documents (Basis-Unterlagen) — the foundation the AI tailors from + db.run(` + CREATE TABLE IF NOT EXISTS basis_dokumente ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + typ TEXT, + name TEXT, + inhalt TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `, (err) => { + if (err) return reject(err); + + // Generated attachment files linked to an application + db.run(` + CREATE TABLE IF NOT EXISTS anhaenge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bewerbung_id INTEGER NOT NULL, + name TEXT, + dateiname TEXT NOT NULL, + mime TEXT, + pfad TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE + ) + `, (err) => { + if (err) return reject(err); + db.run(` CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -164,6 +284,13 @@ function initializeDatabase() { } }); }); + }); + }); + }); + }); + }); + }); + }); }); }); }); @@ -280,6 +407,88 @@ initializeDatabase().then(() => { } }); + // ----- Indeed import (called by the browser extension) ----- + app.post('/api/indeed-import', async (req, res) => { + try { + const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url } = req.body || {}; + + if (!firma || !stelle) { + return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' }); + } + + const datum = new Date().toISOString().split('T')[0]; + // Keep the extra details (location, salary, source) visible in the notes too. + const notizParts = [ + ort ? `Ort: ${ort}` : null, + gehalt ? `Gehalt: ${gehalt}` : null, + quelle_url ? `Quelle: ${quelle_url}` : null, + ].filter(Boolean); + const notizen = notizParts.join('\n'); + + // Stored raw: every view renders these through EJS `<%= %>` (auto-escaped), + // so this is XSS-safe — and it keeps the text clean for the AI and the PDFs + // (no HTML entities leaking into the generated documents). + const result = await dbRun( + `INSERT INTO bewerbungen + (datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status) + VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'ausstehend')`, + [datum, firma, stelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || ''] + ); + + // Record the initial "Entwurf" status in the timeline + await dbRun( + 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', + [result.lastID, datum, 'Entwurf', 'Automatisch über Indeed importiert'] + ); + + // Kick off AI document generation in the background — respond immediately. + runGeneration(result.lastID); + + res.json({ + success: true, + id: result.lastID, + url: `/bewerbung/${result.lastID}`, + message: 'Bewerbung als Entwurf angelegt. Die Unterlagen werden nun generiert.', + }); + } catch (error) { + console.error('Error importing job:', error); + res.status(500).json({ error: 'Serverfehler beim Import.' }); + } + }); + + // Poll generation status + current attachments for one application + app.get('/api/bewerbungen/:id/generierung', async (req, res) => { + try { + const { id } = req.params; + const bewerbung = await dbGet( + 'SELECT id, generierung_status, generierung_fehler FROM bewerbungen WHERE id = ?', [id] + ); + if (!bewerbung) return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); + const anhaenge = await dbAll( + 'SELECT id, name, dateiname, mime FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', [id] + ); + res.json({ + status: bewerbung.generierung_status, + fehler: bewerbung.generierung_fehler, + anhaenge, + }); + } catch (error) { + console.error('Error fetching generation status:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + + // ----- Base documents (Basis-Unterlagen / Vorlagen) ----- + app.get('/api/basis-dokumente', async (req, res) => { + try { + const docs = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC'); + res.json(docs); + } catch (error) { + console.error('Error listing base documents:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + // Create application app.post('/api/bewerbungen', async (req, res) => { try { @@ -385,9 +594,17 @@ initializeDatabase().then(() => { [id] ); + const anhaenge = await dbAll( + 'SELECT id, name, dateiname, mime, created_at FROM anhaenge WHERE bewerbung_id = ? ORDER BY id ASC', + [id] + ); + const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente'); + res.render('bewerbung', { application, verlauf, + anhaenge, + basisCount: basisCountRow ? basisCountRow.count : 0, artOptions: ART_OPTIONS, statusOptions: STATUS_OPTIONS, hideSettings: true @@ -471,6 +688,119 @@ initializeDatabase().then(() => { } }); + // ----- Vorlagen (Basis-Unterlagen) management page ----- + app.get('/vorlagen', async (req, res) => { + try { + const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC'); + res.render('vorlagen', { + basisDokumente, + basisTypOptions: BASIS_TYP_OPTIONS, + hasApiKey: Boolean(process.env.OLLAMA_API_KEY), + hideSettings: true, + }); + } catch (error) { + console.error('Error loading vorlagen:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Add a base document + app.post('/vorlagen', async (req, res) => { + try { + const { typ, name, inhalt } = req.body; + if (inhalt && inhalt.trim()) { + await dbRun( + 'INSERT INTO basis_dokumente (typ, name, inhalt) VALUES (?, ?, ?)', + [sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt] + ); + } + res.redirect('/vorlagen'); + } catch (error) { + console.error('Error adding base document:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Update a base document + app.post('/vorlagen/:id', async (req, res) => { + try { + const { id } = req.params; + const { typ, name, inhalt } = req.body; + await dbRun( + 'UPDATE basis_dokumente SET typ = ?, name = ?, inhalt = ? WHERE id = ?', + [sanitizeInput(typ || 'Sonstiges'), sanitizeInput(name || ''), inhalt || '', id] + ); + res.redirect('/vorlagen'); + } catch (error) { + console.error('Error updating base document:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Delete a base document + app.post('/vorlagen/:id/delete', async (req, res) => { + try { + await dbRun('DELETE FROM basis_dokumente WHERE id = ?', [req.params.id]); + res.redirect('/vorlagen'); + } catch (error) { + console.error('Error deleting base document:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Download a generated attachment + app.get('/anhaenge/:id/download', async (req, res) => { + try { + const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ?', [req.params.id]); + if (!anhang) return res.status(404).send('Anhang nicht gefunden'); + const filePath = path.join(anhaengeDir, anhang.pfad); + if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden'); + res.download(filePath, anhang.dateiname); + } catch (error) { + console.error('Error downloading attachment:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Delete a generated attachment + app.post('/bewerbung/:id/anhaenge/:anhangId/delete', async (req, res) => { + try { + const { id, anhangId } = req.params; + const anhang = await dbGet('SELECT * FROM anhaenge WHERE id = ? AND bewerbung_id = ?', [anhangId, id]); + if (anhang) { + const filePath = path.join(anhaengeDir, anhang.pfad); + fs.promises.unlink(filePath).catch(() => {}); + await dbRun('DELETE FROM anhaenge WHERE id = ?', [anhangId]); + } + res.redirect('/bewerbung/' + id); + } catch (error) { + console.error('Error deleting attachment:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Re-run the AI generation for an application (removes old generated files first) + app.post('/bewerbung/:id/regenerate', async (req, res) => { + try { + const { id } = req.params; + const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]); + if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden'); + + const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]); + for (const a of alte) { + fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {}); + } + await dbRun('DELETE FROM anhaenge WHERE bewerbung_id = ?', [id]); + await dbRun("UPDATE bewerbungen SET generierung_status = 'ausstehend', generierung_fehler = NULL WHERE id = ?", [id]); + + runGeneration(id); + res.redirect('/bewerbung/' + id); + } catch (error) { + console.error('Error regenerating documents:', error); + res.status(500).send('Serverfehler'); + } + }); + // Start server app.listen(PORT, () => { console.log(`Server läuft auf http://localhost:${PORT}`); diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs index 57e003c..ed41c6b 100644 --- a/views/bewerbung.ejs +++ b/views/bewerbung.ejs @@ -8,6 +8,7 @@ <% const statusColor = { + 'Entwurf': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', 'Gesendet': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200', 'Eingangsbestätigung': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', 'Vorstellungsgespräch': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', @@ -93,6 +94,113 @@ + + <% const genStatus = application.generierung_status; %> + <% if (genStatus || (anhaenge && anhaenge.length) || (application.stellenbeschreibung && application.stellenbeschreibung.trim())) { %> +
+
+

Bewerbungsunterlagen

+
+ +
+
+ + +
+ <% if (genStatus === 'ausstehend') { %> +
+ + + + + Die KI erstellt gerade individuelle Bewerbungsunterlagen … Diese Seite aktualisiert sich automatisch. +
+ <% } else if (genStatus === 'fehler') { %> +
+
+ + + + Generierung fehlgeschlagen +
+

<%= application.generierung_fehler || 'Unbekannter Fehler.' %>

+
+ <% } else if (genStatus === 'fertig') { %> +
+ + + + Unterlagen wurden erstellt. Bitte vor dem Versand prüfen und ggf. anpassen. +
+ <% } %> +
+ + +
+ <% if (anhaenge && anhaenge.length) { %> +
    + <% anhaenge.forEach(a => { %> +
  • +
    + + + +
    +

    <%= a.name || a.dateiname %>

    +

    <%= a.dateiname %>

    +
    +
    +
    + + + + + Download + +
    + +
    +
    +
  • + <% }); %> +
+ <% } else if (genStatus !== 'ausstehend') { %> +

Noch keine generierten Unterlagen vorhanden.

+ <% } %> +
+ + + <% if (application.stellenbeschreibung && application.stellenbeschreibung.trim()) { %> +
+ + Importierte Stellenbeschreibung anzeigen + +
+ <% if (application.quelle_url) { %> + <%= application.quelle_url %> + <% } %> +

<%= application.stellenbeschreibung %>

+
+
+ <% } %> +
+ <% } %> +

Status-Verlauf

@@ -214,6 +322,24 @@ }); const cy = document.getElementById('currentYear'); if (cy) cy.textContent = new Date().getFullYear(); + + // While documents are being generated, poll for completion and + // reload the page once the status changes. + const unterlagen = document.getElementById('unterlagen'); + if (unterlagen && unterlagen.dataset.status === 'ausstehend') { + const appId = <%= application.id %>; + const poll = setInterval(async () => { + try { + const res = await fetch('/api/bewerbungen/' + appId + '/generierung', { cache: 'no-store' }); + if (!res.ok) return; + const data = await res.json(); + if (data.status && data.status !== 'ausstehend') { + clearInterval(poll); + window.location.reload(); + } + } catch (e) { /* keep polling */ } + }, 4000); + } })(); diff --git a/views/index.ejs b/views/index.ejs index 4eb57bb..95c1d79 100644 --- a/views/index.ejs +++ b/views/index.ejs @@ -144,9 +144,10 @@ <% if (applications.length > 0) { %> <% // Status-Reihenfolge synchron zu public/js/main.js halten - const statusOrder = ['Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch', + const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch', 'Einstellung', 'Absage', 'Keine Rückmeldung']; const statusDot = { + 'Entwurf': 'bg-purple-500', 'Gesendet': 'bg-indigo-500', 'Eingangsbestätigung': 'bg-blue-500', 'Vorstellungsgespräch': 'bg-yellow-500', @@ -204,6 +205,7 @@ <%= 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 === 'Vorstellungsgespräch' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-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 || '-' %> diff --git a/views/partials/header.ejs b/views/partials/header.ejs index 9bade03..2ae4d1d 100644 --- a/views/partials/header.ejs +++ b/views/partials/header.ejs @@ -9,8 +9,18 @@
+ + + + + + + + - +
+ +
+ +
+ + <% }); %> +
+ + +
+

Basis-Dokument hinzufügen

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+
+ + + <%- include('partials/footer') %> + + + +