From b1c92dd77b697e56ece0ebb652c0811147c5bda1 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Fri, 3 Jul 2026 18:32:17 +0200 Subject: [PATCH] Guard against duplicate applications + capture jobs on any website MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicate safeguard: before creating an application (manual add and browser import) the server checks for an existing one for the same job — matched on a normalised source URL (job-id params like Indeed's jk pin the posting across paths/tracking) or an identical company + role (case/umlaut/whitespace-insensitive). On a match it returns 409 with the matches; the web form and the extension show the existing entry and re-submit with force=true only if the user confirms. Not a hard block, so legitimate re-applications stay possible. Universal capture: the extension popup becomes an editable capture form that works on any site. It extracts the active page on demand (schema.org JobPosting JSON-LD -> OpenGraph/meta -> h1/title/selection -> canonical URL), lets the user review/correct, and sends. The import route is now source-agnostic and derives the application source (art) from the URL instead of hardcoding Indeed; the Indeed on-page button remains as a fast path. Adds scripting/activeTab permissions. Co-Authored-By: Claude Opus 4.8 --- extension/background.js | 10 ++ extension/content.js | 76 +++++++++------ extension/manifest.json | 8 +- extension/popup.html | 89 ++++++++++++++---- extension/popup.js | 198 ++++++++++++++++++++++++++++++++++++---- public/js/main.js | 59 ++++++++---- server.js | 104 ++++++++++++++++++++- 7 files changed, 457 insertions(+), 87 deletions(-) diff --git a/extension/background.js b/extension/background.js index 49a7041..2bbe2d6 100644 --- a/extension/background.js +++ b/extension/background.js @@ -29,6 +29,16 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { try { data = await res.json(); } catch (_) { /* non-JSON response */ } if (!res.ok) { + // Duplicate guard: surface the existing matches so the user can decide. + if (res.status === 409 && data && data.duplicate) { + sendResponse({ + ok: false, + duplicate: true, + matches: data.matches || [], + error: (data && data.error) || 'Für diese Stelle existiert bereits eine Bewerbung.', + }); + return; + } sendResponse({ ok: false, error: (data && data.error) || `Server antwortete mit ${res.status}`, diff --git a/extension/content.js b/extension/content.js index 512efae..67e2f29 100644 --- a/extension/content.js +++ b/extension/content.js @@ -84,6 +84,7 @@ gehalt: getSalary(root), stellenbeschreibung: getDescription(root), quelle_url: getSourceUrl(), + art: 'Indeed', }; } @@ -119,6 +120,16 @@ status.innerHTML = html; } + function duplicateWarning(matches) { + const lines = (matches || []).slice(0, 5).map(function (m) { + const parts = [m.firma, m.stelle].filter(Boolean).join(' — '); + const meta = [m.datum, m.status].filter(Boolean).join(', '); + return '• ' + parts + (meta ? ' (' + meta + ')' : ''); + }); + return 'Für diese Stelle scheint bereits eine Bewerbung zu existieren:\n\n' + + lines.join('\n') + '\n\nTrotzdem als weitere Bewerbung importieren?'; + } + function onClick(btn, status) { const job = scrapeJob(); @@ -127,37 +138,50 @@ 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') + ''; + const send = (payload) => { + btn.disabled = true; + btn.innerHTML = 'Wird gesendet…'; + setStatus(status, '', ''); - 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; - } + chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: payload }, (resp) => { + btn.innerHTML = ICON_SEND + '' + (originalLabel || 'An Bewerbungs-Tracker senden') + ''; - 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); - }); + if (chrome.runtime.lastError) { + btn.disabled = false; + setStatus(status, 'Fehler: ' + chrome.runtime.lastError.message, 'bt-err'); + return; + } + if (resp && resp.duplicate) { + btn.disabled = false; + if (confirm(duplicateWarning(resp.matches))) { + send(Object.assign({}, payload, { force: true })); + } else { + setStatus(status, 'Import abgebrochen (mögliches Duplikat).', ''); + } + 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' + ); + setTimeout(() => { btn.disabled = false; }, 1500); + }); + }; + + send(job); } // ----- Injection + SPA handling ----------------------------------------- diff --git a/extension/manifest.json b/extension/manifest.json index 6dabded..9b4660a 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,9 +1,9 @@ { "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"], + "name": "Bewerbungs-Tracker – Stellen-Import", + "version": "1.1.0", + "description": "Erfasst Stellen von jeder Webseite (nicht nur Indeed) über das Erweiterungs-Popup und sendet sie an den Bewerbungs-Tracker, der automatisch zugeschnittene Bewerbungsunterlagen erstellt.", + "permissions": ["storage", "scripting", "activeTab"], "host_permissions": [ "*://*.indeed.com/*", "http://localhost/*", diff --git a/extension/popup.html b/extension/popup.html index 26be571..626e68a 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -5,48 +5,105 @@ -

Bewerbungs-Tracker

-

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

+

Stelle erfassen

+

Automatisch von der aktuellen Seite ausgelesen - bitte prüfen, ggf. korrigieren und senden. Tipp: Text auf der Seite markieren, dann wird er als Beschreibung übernommen.

- - +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+
+ + +
+
+ + + + +
- - + +
+
+ Einstellungen + + +
+ + +
+
+ diff --git a/extension/popup.js b/extension/popup.js index 56c3b33..0db3721 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,41 +1,201 @@ const DEFAULT_TRACKER_URL = 'http://localhost:3000'; -const input = document.getElementById('trackerUrl'); -const statusEl = document.getElementById('status'); +const $ = (id) => document.getElementById(id); +const statusEl = $('status'); function normalize(url) { return (url || '').trim().replace(/\/+$/, ''); } - -function setStatus(text, cls) { - statusEl.textContent = text; +function setStatus(html, cls) { + statusEl.innerHTML = html; statusEl.className = cls || ''; } -// Load stored value +// ---- Settings (tracker URL) ------------------------------------------------ + chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => { - input.value = items.trackerUrl || DEFAULT_TRACKER_URL; + $('trackerUrl').value = items.trackerUrl || DEFAULT_TRACKER_URL; }); -document.getElementById('saveBtn').addEventListener('click', () => { - const url = normalize(input.value) || DEFAULT_TRACKER_URL; +$('saveBtn').addEventListener('click', () => { + const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL; chrome.storage.sync.set({ trackerUrl: url }, () => { - input.value = url; - setStatus('Gespeichert.', 'ok'); + $('trackerUrl').value = url; + setStatus('Einstellungen gespeichert.', 'ok'); }); }); -document.getElementById('testBtn').addEventListener('click', async () => { - const url = normalize(input.value) || DEFAULT_TRACKER_URL; - setStatus('Teste Verbindung…', ''); +$('testBtn').addEventListener('click', async () => { + const url = normalize($('trackerUrl').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'); - } + setStatus(res.ok ? 'Verbindung erfolgreich ✓' : 'Erreichbar, aber unerwartete Antwort (' + res.status + ').', res.ok ? 'ok' : 'err'); } catch (err) { setStatus('Nicht erreichbar: ' + err.message, 'err'); } }); + +// ---- Page extraction (injected into the active tab) ------------------------ + +// Runs in the page context. Best-effort job extraction from ANY site: +// schema.org JobPosting (JSON-LD) → OpenGraph/meta →

/selection → canonical. +function extractJobFromPage() { + const txt = (s) => (s || '').replace(/\s+/g, ' ').trim(); + const meta = (sel) => { const el = document.querySelector(sel); return el ? (el.getAttribute('content') || '') : ''; }; + + let jp = null; + document.querySelectorAll('script[type="application/ld+json"]').forEach((s) => { + if (jp) return; + let data; try { data = JSON.parse(s.textContent); } catch (e) { return; } + const arr = Array.isArray(data) ? data : (data['@graph'] ? data['@graph'] : [data]); + for (const node of arr) { + const t = node && node['@type']; + if (t === 'JobPosting' || (Array.isArray(t) && t.indexOf('JobPosting') !== -1)) { jp = node; break; } + } + }); + + const out = { firma: '', stelle: '', ort: '', gehalt: '', stellenbeschreibung: '', quelle_url: '' }; + + if (jp) { + out.stelle = txt(jp.title || ''); + const org = jp.hiringOrganization; + if (org) out.firma = txt(typeof org === 'string' ? org : (org.name || '')); + const loc = Array.isArray(jp.jobLocation) ? jp.jobLocation[0] : jp.jobLocation; + if (loc && loc.address) { + const a = loc.address; + out.ort = txt(typeof a === 'string' ? a : [a.addressLocality, a.addressRegion, a.postalCode].filter(Boolean).join(', ')); + } + const sal = jp.baseSalary; + if (sal && sal.value) { + const v = sal.value; + const amount = v.value || (v.minValue != null && v.maxValue != null ? v.minValue + '-' + v.maxValue : (v.minValue || v.maxValue || '')); + if (amount) out.gehalt = txt(String(amount) + ' ' + (sal.currency || '') + (v.unitText ? (' / ' + v.unitText) : '')); + } + if (jp.description) { + const tmp = document.createElement('div'); + tmp.innerHTML = jp.description; + out.stellenbeschreibung = txt(tmp.innerText || tmp.textContent || ''); + } + } + + if (!out.stelle) { + const h1 = document.querySelector('h1'); + out.stelle = txt(meta('meta[property="og:title"]') || (h1 ? h1.innerText : '') || document.title); + } + if (!out.firma) { + out.firma = txt(meta('meta[property="og:site_name"]') || meta('meta[name="author"]') || location.hostname.replace(/^www\./, '')); + } + + // A non-trivial user text selection wins as the description (clear intent). + const sel = txt(window.getSelection ? String(window.getSelection()) : ''); + if (sel && sel.length > 40) out.stellenbeschreibung = sel; + if (!out.stellenbeschreibung) out.stellenbeschreibung = txt(meta('meta[name="description"]') || meta('meta[property="og:description"]')); + + const canon = document.querySelector('link[rel="canonical"]'); + out.quelle_url = (canon && canon.href) || meta('meta[property="og:url"]') || location.href; + return out; +} + +// Infer the "source" (art) select value from a URL host — mirrors the server. +function deriveArt(url) { + const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase(); + if (!host) return 'Sonstiges'; + if (host.indexOf('indeed') !== -1) return 'Indeed'; + if (host.indexOf('stepstone') !== -1) return 'StepStone'; + if (host.indexOf('arbeitsagentur') !== -1) return 'Arbeitsagentur'; + if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal'; + return 'Firmenwebsite'; +} + +function fillForm(data) { + data = data || {}; + $('firma').value = data.firma || ''; + $('stelle').value = data.stelle || ''; + $('ort').value = data.ort || ''; + $('gehalt').value = data.gehalt || ''; + $('stellenbeschreibung').value = data.stellenbeschreibung || ''; + $('quelle_url').value = data.quelle_url || ''; + const art = deriveArt(data.quelle_url); + const sel = $('art'); + for (const opt of sel.options) { if (opt.value === art) { sel.value = art; break; } } +} + +async function scanActivePage() { + setStatus('Lese Seite aus …', ''); + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab || !tab.id) { setStatus('Keine aktive Seite gefunden.', 'err'); return; } + const results = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: extractJobFromPage }); + const data = results && results[0] && results[0].result; + fillForm(data); + if (data && (data.stelle || data.firma)) setStatus('Ausgelesen - bitte prüfen und senden.', 'ok'); + else setStatus('Konnte wenig auslesen - bitte manuell ergänzen.', ''); + } catch (err) { + // chrome://, Web Store and a few CSP-locked pages can't be scripted. + setStatus('Diese Seite kann nicht automatisch ausgelesen werden - bitte manuell ausfüllen.', ''); + } +} + +// ---- Sending (via the background service worker) --------------------------- + +function duplicateWarning(matches) { + const lines = (matches || []).slice(0, 5).map((m) => { + const parts = [m.firma, m.stelle].filter(Boolean).join(' — '); + const meta = [m.datum, m.status].filter(Boolean).join(', '); + return '• ' + parts + (meta ? ' (' + meta + ')' : ''); + }); + return 'Für diese Stelle scheint bereits eine Bewerbung zu existieren:\n\n' + + lines.join('\n') + '\n\nTrotzdem als weitere Bewerbung anlegen?'; +} + +function sendJob(payload) { + $('sendBtn').disabled = true; + setStatus('Wird gesendet …', ''); + chrome.runtime.sendMessage({ type: 'IMPORT_JOB', payload: payload }, (resp) => { + if (chrome.runtime.lastError) { + $('sendBtn').disabled = false; + setStatus('Fehler: ' + chrome.runtime.lastError.message, 'err'); + return; + } + if (resp && resp.duplicate) { + $('sendBtn').disabled = false; + if (confirm(duplicateWarning(resp.matches))) { + sendJob(Object.assign({}, payload, { force: true })); + } else { + setStatus('Abgebrochen (mögliches Duplikat).', ''); + } + return; + } + if (!resp || !resp.ok) { + $('sendBtn').disabled = false; + setStatus((resp && resp.error) || 'Unbekannter Fehler.', 'err'); + return; + } + const link = resp.openUrl ? ' Entwurf öffnen' : ''; + setStatus('✓ ' + (resp.message || 'Als Entwurf angelegt.') + link, 'ok'); + setTimeout(() => { $('sendBtn').disabled = false; }, 1500); + }); +} + +$('sendBtn').addEventListener('click', () => { + const payload = { + firma: $('firma').value.trim(), + stelle: $('stelle').value.trim(), + ort: $('ort').value.trim(), + gehalt: $('gehalt').value.trim(), + stellenbeschreibung: $('stellenbeschreibung').value.trim(), + quelle_url: $('quelle_url').value.trim(), + art: $('art').value, + }; + if (!payload.firma || !payload.stelle) { + setStatus('Bitte mindestens Firma und Stelle angeben.', 'err'); + return; + } + sendJob(payload); +}); + +$('rescanBtn').addEventListener('click', scanActivePage); + +// Auto-extract as soon as the popup opens. +scanActivePage(); diff --git a/public/js/main.js b/public/js/main.js index f4b130a..e0e3d11 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -189,29 +189,52 @@ function saveApplication(event) { let url = '/api/bewerbungen'; let method = 'POST'; - + if (currentApplicationId) { url = `/api/bewerbungen/${currentApplicationId}`; method = 'PUT'; } - - fetch(url, { + + const send = (payload) => fetch(url, { method: method, - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(application) - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - hideModal(applicationModal); - resetApplicationForm(); - // Refresh the page to show updated data - location.reload(); - } - }) - .catch(error => console.error('Error saving application:', error)); + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + send(application) + .then(async (response) => { + // Duplicate guard: the server flags a likely double application (409). + if (response.status === 409) { + const info = await response.json().catch(() => ({})); + if (info.duplicate) { + if (confirm(duplicateWarning(info.matches))) { + return send(Object.assign({}, application, { force: true })).then(r => r.json()); + } + return null; // user cancelled — keep the form open + } + } + return response.json(); + }) + .then(data => { + if (data && data.success) { + hideModal(applicationModal); + resetApplicationForm(); + location.reload(); + } + }) + .catch(error => console.error('Error saving application:', error)); +} + +// Human-readable warning listing the existing application(s) that look like the +// same job, shown before a possible double application is created. +function duplicateWarning(matches) { + const lines = (matches || []).slice(0, 5).map(m => { + const parts = [m.firma, m.stelle].filter(Boolean).join(' — '); + const meta = [m.datum, m.status].filter(Boolean).join(', '); + return '• ' + parts + (meta ? ' (' + meta + ')' : ''); + }); + return 'Es gibt bereits eine passende Bewerbung:\n\n' + lines.join('\n') + + '\n\nTrotzdem eine weitere Bewerbung für diese Stelle anlegen?'; } function openDeleteModal(id) { diff --git a/server.js b/server.js index edd69f7..da3dcf8 100644 --- a/server.js +++ b/server.js @@ -46,6 +46,20 @@ const STATUS_OPTIONS = [ // Base document types the user can provide as a foundation for AI tailoring const BASIS_TYP_OPTIONS = ['Anschreiben', 'Lebenslauf', 'Profil/Kurzprofil', 'Sonstiges']; +// Pick the application "source" (art) for a browser-captured job. Honour an +// explicit value from the extension, otherwise infer it from the URL host so a +// capture from any website is labelled sensibly. +function deriveArt(url, provided) { + if (provided && ART_OPTIONS.includes(provided)) return provided; + const host = (String(url || '').match(/^https?:\/\/([^/]+)/i) || [, ''])[1].toLowerCase(); + if (!host) return 'Sonstiges'; + if (host.includes('indeed')) return 'Indeed'; + if (host.includes('stepstone')) return 'StepStone'; + if (host.includes('arbeitsagentur')) return 'Arbeitsagentur'; + if (/(linkedin|xing|monster|stellenanzeigen|kimeta|glassdoor|jobware|meinestadt|jobs\.|karriere\.)/.test(host)) return 'Online-Portal'; + return 'Firmenwebsite'; +} + // Middleware app.use(express.json({ limit: '2mb' })); app.use(express.urlencoded({ extended: true, limit: '2mb' })); @@ -317,6 +331,58 @@ async function pollInbox() { } } +// --------------------------------------------------------------------------- +// Duplicate-application guard: spot an existing application for the same job so +// the user doesn't accidentally apply twice. Matches on a normalised source URL +// (strongest signal for imported postings) or an identical company + role. It +// only warns — legitimate re-applications stay possible via a "force" flag. +// --------------------------------------------------------------------------- +function normText(s) { + return String(s == null ? '' : s) + .toLowerCase() + .normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics (ä→a …) + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function normUrl(u) { + const raw = String(u == null ? '' : u).trim(); + if (!raw) return ''; + try { + const url = new URL(raw); + const host = url.hostname.replace(/^www\./, '').toLowerCase(); + // A job-identifying query param (Indeed jk/vjk, generic ids) pins the posting + // regardless of tracking params or which path it was opened from. + const idKeys = ['jk', 'vjk', 'jobkey', 'jobid', 'vacancyid', 'stellenangebotid', 'positionid', 'offerid', 'id']; + let idPart = ''; + for (const [k, v] of url.searchParams.entries()) { + if (v && idKeys.includes(k.toLowerCase())) { idPart = k.toLowerCase() + '=' + v.toLowerCase(); break; } + } + const pathn = url.pathname.replace(/\/+$/, '').toLowerCase(); + return idPart ? host + '|' + idPart : host + pathn; + } catch (e) { + return raw.toLowerCase().replace(/[?#].*$/, ''); + } +} + +// Existing applications that look like the same job as {firma, stelle, quelle_url}. +// `excludeId` skips a specific row (e.g. when re-checking during an edit). +async function findDuplicateApplications({ firma, stelle, quelle_url, excludeId }) { + const rows = await dbAll('SELECT id, datum, firma, stelle, ort, quelle_url, status FROM bewerbungen'); + const fUrl = normUrl(quelle_url); + const fFirma = normText(firma); + const fStelle = normText(stelle); + const matches = []; + for (const r of rows) { + if (excludeId && Number(r.id) === Number(excludeId)) continue; + let reason = null; + if (fUrl && normUrl(r.quelle_url) === fUrl) reason = 'url'; + else if (fFirma && fStelle && normText(r.firma) === fFirma && normText(r.stelle) === fStelle) reason = 'firma_stelle'; + if (reason) matches.push({ id: r.id, datum: r.datum, firma: r.firma, stelle: r.stelle, ort: r.ort, status: r.status, reason }); + } + return matches; +} + // Recompute an application's current status from its latest timeline entry async function syncCurrentStatus(bewerbungId) { const latest = await dbGet( @@ -697,12 +763,28 @@ 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 || {}; + const { firma, stelle, ort, gehalt, stellenbeschreibung, quelle_url, art } = req.body || {}; if (!firma || !stelle) { return res.status(400).json({ error: 'Firma und Stelle sind erforderlich.' }); } + // Source of the capture: honour an explicit art, else infer from the URL. + const quelle = deriveArt(quelle_url, art); + + // Duplicate guard: don't silently import the same posting twice. + const forceImport = req.body.force === true || req.body.force === 'true'; + if (!forceImport) { + const dups = await findDuplicateApplications({ firma, stelle, quelle_url }); + if (dups.length) { + return res.status(409).json({ + duplicate: true, + matches: dups, + error: 'Für diese Stelle existiert bereits eine Bewerbung.', + }); + } + } + const datum = new Date().toISOString().split('T')[0]; // Keep the extra details (location, salary, source) visible in the notes too. const notizParts = [ @@ -718,14 +800,14 @@ initializeDatabase().then(() => { const result = await dbRun( `INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status) - VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`, - [datum, firma, stelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || ''] + VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`, + [datum, firma, stelle, quelle, 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'] + [result.lastID, datum, 'Entwurf', `Automatisch aus dem Browser importiert (${quelle})`] ); // Note: generation is NOT started automatically — the user reviews the draft, @@ -780,6 +862,20 @@ initializeDatabase().then(() => { try { const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body; + // Duplicate guard: warn before creating a second application for the same + // company + role (the client re-submits with force=true to confirm). + const force = req.body.force === true || req.body.force === 'true'; + if (!force) { + const dups = await findDuplicateApplications({ firma, stelle }); + if (dups.length) { + return res.status(409).json({ + duplicate: true, + matches: dups, + error: 'Es gibt bereits eine Bewerbung für dieselbe Firma und Stelle.', + }); + } + } + const result = await dbRun( 'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen) VALUES (?, ?, ?, ?, ?, ?, ?)', [datum, sanitizeInput(firma), sanitizeInput(stelle),