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 %>
+
+
+
+
+ <% }); %>
+
+ <% } else if (genStatus !== 'ausstehend') { %>
+
Noch keine generierten Unterlagen vorhanden.
+ <% } %>
+
+
+
+ <% if (application.stellenbeschreibung && application.stellenbeschreibung.trim()) { %>
+
+
+ Importierte Stellenbeschreibung anzeigen
+
+
+
+ <% } %>
+
+ <% } %>
+
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);
+ }
})();