diff --git a/docs/08-anleitung-benutzer.md b/docs/08-anleitung-benutzer.md
index 1d96739..e896733 100644
--- a/docs/08-anleitung-benutzer.md
+++ b/docs/08-anleitung-benutzer.md
@@ -212,7 +212,8 @@ Kontakt (*Kontakt → Stundensatz vor Ort / remote*).
## Scanner
Sucht in Jobbörsen (Agentur für Arbeit, StepStone, karriere.at, Adzuna,
-freelancermap) nach offenen Stellen in DACH, höchstens 21 Tage alt. Technik
+freelancermap) nach offenen Stellen in DACH – jedes Alter; im Dashboard filtert
+*Anzeigenalter* nach „bis 21 Tage“ oder „älter als 21 Tage“. Technik
siehe [12 Scanner](12-scanner.md).
1. **Scanner → Suchagenten → + Neuer Suchagent.** Name, Schlagwörter (Enter
@@ -228,7 +229,11 @@ siehe [12 Scanner](12-scanner.md).
zur Börse. Status setzen: *In Bearbeitung*, wenn sich jemand kümmert,
*Verworfen*, wenn es nicht passt.
-Zeitplan (Wochentage, Uhrzeit, Intervall, Höchstalter) und den kostenlosen
+**Schlagwörter mit Alarm** im Suchagenten: wird eines gefunden, gibt es einen
+kurzen Ton und ein Pop-up – egal, wo du im CRM gerade bist. *Gesehen*
+quittiert für alle.
+
+Zeitplan (Wochentage, Uhrzeit, Intervall) und den kostenlosen
Schlüssel für Adzuna pflegt ein Administrator unter
*Einstellungen → Scanner*.
diff --git a/docs/12-scanner.md b/docs/12-scanner.md
index 3195cd1..2a7dfec 100644
--- a/docs/12-scanner.md
+++ b/docs/12-scanner.md
@@ -1,8 +1,12 @@
# 12 Scanner
Der Scanner durchsucht Jobbörsen nach offenen Stellen, die ICON besetzen kann –
-Festanstellungen ebenso wie Projekte. Region immer DACH, Anzeigen höchstens
-21 Tage alt (einstellbar). Eingeführt am 29.09.2026.
+Festanstellungen ebenso wie Projekte. Region immer DACH. Eingeführt am 29.09.2026.
+
+Gesucht wird **ohne Altersgrenze** (Änderung vom 29.09.2026, vorher höchstens
+21 Tage): auch ältere Anzeigen kommen herein. Im Dashboard filtert
+**Anzeigenalter** nach „bis 21 Tage" bzw. „älter als 21 Tage", die Sortierung
+kennt „älteste Anzeige".
Menü: **Scanner** (Dashboard) mit dem Unterpunkt **Suchagenten**; Zeitplan und
API-Schlüssel unter **Einstellungen → Scanner** (nur Administratoren).
@@ -16,10 +20,10 @@ einfacher HTTP-Abruf (`server/src/scanner/sources.js`).
| Id | Börse | Land | Weg | Schlüssel |
| --- | --- | --- | --- | --- |
-| `ba` | Agentur für Arbeit | DE | offizielle Jobsuche-API v6, JSON, Filter `veroeffentlichtseit` | keiner (öffentlicher Header `X-API-Key: jobboerse-jobsuche`) |
+| `ba` | Agentur für Arbeit | DE | offizielle Jobsuche-API v6, JSON, 100 Anzeigen je Begriff | keiner (öffentlicher Header `X-API-Key: jobboerse-jobsuche`) |
| `stepstone` | StepStone.de | DE | HTML der Trefferliste, `data-at`-Attribute | keiner |
| `karriere` | karriere.at | AT | HTML der Trefferliste, `.m-jobsListItem` | keiner |
-| `adzuna` | Adzuna | DE · AT · CH | REST-API, `max_days_old` | kostenlos, App ID + Key |
+| `adzuna` | Adzuna | DE · AT · CH | REST-API, neueste zuerst | kostenlos, App ID + Key |
| `freelancermap` | freelancermap | DE | HTML, Selektoren wie im perinco-Scanner | keiner |
Adzuna ist die einzige Quelle für die Schweiz (jobs.ch blockt mit CAPTCHA).
@@ -59,7 +63,6 @@ sequenceDiagram
loop je Börse × Schlagwort (1,5 s Pause)
P->>B: Suche
B-->>P: Anzeigen
- P->>P: älter als maxAge? verwerfen
end
loop je Anzeige
P->>DB: speichern (eindeutig je Börse + Kennung), Firma abgleichen
@@ -101,6 +104,34 @@ mit „SAP FI" im Text → (2 + 1 + 0) / 6 = **50 %**.
Das kostet nichts und ist nachvollziehbar; OpenAI wird vom Scanner nicht
aufgerufen.
+## Schlagwörter mit Alarm
+
+Neben den Schlagwörtern trägt ein Agent **Schlagwörter mit Alarm**
+(`alarm_keywords`). Sie werden genauso gesucht und für die Quote gezählt; steht
+eines in einer **neu gefundenen** Anzeige, bekommt der Treffer `alarm = 1`.
+
+```mermaid
+sequenceDiagram
+ participant P as pipeline.js
+ participant DB as icon_scanner_matches
+ participant W as AlarmWatcher (jede Ansicht, jede Minute)
+ P->>DB: Treffer mit alarm = 1, alarm_ack = 0
+ W->>DB: GET /api/scanner/alarms
+ DB-->>W: unquittierte Alarme
+ W->>W: neuer Alarm? Zweiklang (WebAudio) + Pop-up
+ W->>DB: Gesehen / Zum Scanner → POST /alarms/ack
+```
+
+- Der Wächter hängt in `App.tsx`, nicht im Dashboard – der Alarm kommt also in
+ jeder Ansicht (beim perinco-Scanner hörte man den Ton nur im Dashboard).
+- **Quittieren gilt für alle Benutzer.** Sonst klingelte derselbe Fund bei jedem
+ einzeln.
+- Der Ton braucht einen vorherigen Klick auf der Seite (Autoplay-Regel der
+ Browser). Ohne bleibt es still, das Pop-up kommt trotzdem.
+- Schon gespeicherte Treffer werden nicht nachträglich zu Alarmen, wenn man ein
+ Alarm-Wort später ergänzt (Treffer sind eindeutig je Agent und Anzeige).
+- Im Dashboard tragen Alarm-Treffer eine rote Glocke; Filter **nur Alarm**.
+
## Bekannte Unternehmen
Jede Anzeige wird mit `icon_companies` abgeglichen. Beide Namen werden
@@ -118,8 +149,7 @@ angelegtes Unternehmen wird so auch bei den Anzeigen von gestern erkannt.
## Zeitplan
-Einstellungen → Scanner. Vorgabe: **stündlich, Mo–Fr, 06:00–20:00 Uhr**,
-Anzeigen höchstens **21 Tage** alt.
+Einstellungen → Scanner. Vorgabe: **stündlich, Mo–Fr, 06:00–20:00 Uhr**.
```mermaid
flowchart TD
@@ -151,7 +181,6 @@ Liste neu, sobald er fertig ist.
| `scanner_days` | `1,2,3,4,5` | ISO-Wochentage, 1 = Montag |
| `scanner_from` / `scanner_to` | `06:00` / `20:00` | Fenster, deutsche Zeit |
| `scanner_interval` | `60` | Minuten, 15 bis 1440 |
-| `scanner_max_age` | `21` | Tage, 1 bis 90 |
| `scanner_adzuna_id`, `scanner_adzuna_key` | leer | gehen nie im Klartext zum Browser |
| `scanner_last_scan_at` | – | Beginn des letzten Laufs (ISO) |
@@ -168,6 +197,7 @@ erDiagram
text keywords "JSON-Array"
text sources "JSON-Array der Quellen-Ids"
text excludes "JSON-Array"
+ text alarm_keywords "JSON-Array, Ton + Pop-up"
tinyint is_active
}
icon_scanner_jobs {
@@ -189,6 +219,8 @@ erDiagram
tinyint score "0-100"
text hits "gefundene Schlagwörter"
enum status "neu gesehen in_bearbeitung verworfen"
+ tinyint alarm "Alarm-Schlagwort getroffen"
+ tinyint alarm_ack "Pop-up quittiert (für alle)"
}
icon_scanner_runs {
int id
@@ -220,10 +252,12 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`.
| --- | --- | --- | --- |
| GET | `/overview` | alle | Kennzahlen, Quellen samt Ergebnis des letzten Laufs, Zeitplan, läuft gerade ein Lauf |
| GET | `/agents` | alle | Suchagenten mit Trefferzahl |
-| POST | `/agents` | alle | anlegen `{ name, keywords[], sources[], excludes[], is_active }` |
+| POST | `/agents` | alle | anlegen `{ name, keywords[], alarm_keywords[], sources[], excludes[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht |
| PUT | `/agents/:id` | alle | ändern (gleicher Körper) |
| DELETE | `/agents/:id` | alle | löschen, Treffer gehen mit |
-| GET | `/matches` | alle | Filter `hours`, `agentId`, `source`, `known=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`published`), `limit` |
+| GET | `/matches` | alle | Filter `hours` (gefunden), `age` (`recent`/`old`, Grenze 21 Tage ab Veröffentlichung), `agentId`, `source`, `known=1`, `alarm=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`published`/`oldest`), `limit` |
+| GET | `/alarms` | alle | unquittierte Alarm-Treffer (höchstens 50) |
+| POST | `/alarms/ack` | alle | `{ ids: [] }` – quittiert für alle Benutzer |
| PATCH | `/matches/:id` | alle | `{ status }` |
| POST | `/scan` | alle | Lauf starten, `202`; `409`, wenn schon einer läuft |
| GET | `/settings` | Admin | Zeitplan, Schlüssel verkürzt |
diff --git a/server/src/db.js b/server/src/db.js
index 482e1fc..50bc7ac 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -185,6 +185,19 @@ async function migrateColumns() {
'email',
'ALTER TABLE icon_suspects ADD COLUMN email VARCHAR(190) NULL AFTER company',
],
+ // Scanner: Schlagwoerter mit Alarm (Ton + Pop-up beim Fund), 29.09.2026.
+ [
+ 'icon_scanner_agents',
+ 'alarm_keywords',
+ 'ALTER TABLE icon_scanner_agents ADD COLUMN alarm_keywords TEXT NULL AFTER excludes',
+ ],
+ [
+ 'icon_scanner_matches',
+ 'alarm',
+ `ALTER TABLE icon_scanner_matches
+ ADD COLUMN alarm TINYINT(1) NOT NULL DEFAULT 0 AFTER status,
+ ADD COLUMN alarm_ack TINYINT(1) NOT NULL DEFAULT 0 AFTER alarm`,
+ ],
[
'icon_cv_imports',
'photo',
diff --git a/server/src/routes/scanner.js b/server/src/routes/scanner.js
index 6e4b49e..f3340c8 100644
--- a/server/src/routes/scanner.js
+++ b/server/src/routes/scanner.js
@@ -24,6 +24,8 @@ const router = express.Router();
router.use(requireAuth);
const STATUSES = ['neu', 'gesehen', 'in_bearbeitung', 'verworfen'];
+/** Grenze des Filters "Anzeigenalter" im Dashboard. */
+const AGE_DAYS = 21;
const cleanList = (value) =>
[...new Set((Array.isArray(value) ? value : []).map(stripQuotes).filter(Boolean))].slice(0, 50);
@@ -35,6 +37,7 @@ const agentOut = (row) => ({
// Nur Quellen, die es noch gibt (Jooble ist am 29.09.2026 entfallen).
sources: parseList(row.sources).filter((id) => SOURCE_IDS.includes(id)),
excludes: parseList(row.excludes),
+ alarm_keywords: parseList(row.alarm_keywords),
is_active: Boolean(row.is_active),
matches: Number(row.matches ?? 0),
created_at: row.created_at,
@@ -118,27 +121,33 @@ function readAgent(body) {
const keywords = cleanList(body?.keywords);
const sources = cleanList(body?.sources).filter((id) => SOURCE_IDS.includes(id));
const excludes = cleanList(body?.excludes);
+ const alarmKeywords = cleanList(body?.alarm_keywords);
if (!name) return { error: 'Bitte einen Namen angeben.' };
- if (keywords.length === 0) return { error: 'Mindestens ein Schlagwort angeben.' };
+ if (keywords.length === 0 && alarmKeywords.length === 0) {
+ return { error: 'Mindestens ein Schlagwort oder Alarm-Schlagwort angeben.' };
+ }
if (sources.length === 0) return { error: 'Mindestens eine Börse auswählen.' };
- return { name, keywords, sources, excludes, is_active: body?.is_active !== false };
+ return { name, keywords, alarmKeywords, sources, excludes, is_active: body?.is_active !== false };
}
+/** Spaltenwerte in der Reihenfolge name, keywords, alarm_keywords, sources, excludes, is_active. */
+const agentValues = (agent) => [
+ agent.name,
+ JSON.stringify(agent.keywords),
+ JSON.stringify(agent.alarmKeywords),
+ JSON.stringify(agent.sources),
+ JSON.stringify(agent.excludes),
+ agent.is_active ? 1 : 0,
+];
+
router.post('/agents', async (req, res, next) => {
const agent = readAgent(req.body);
if (agent.error) return res.status(400).json({ error: agent.error });
try {
const [result] = await pool.query(
- `INSERT INTO icon_scanner_agents (name, keywords, sources, excludes, is_active, created_by)
- VALUES (?, ?, ?, ?, ?, ?)`,
- [
- agent.name,
- JSON.stringify(agent.keywords),
- JSON.stringify(agent.sources),
- JSON.stringify(agent.excludes),
- agent.is_active ? 1 : 0,
- req.user.id,
- ]
+ `INSERT INTO icon_scanner_agents (name, keywords, alarm_keywords, sources, excludes, is_active, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ [...agentValues(agent), req.user.id]
);
await logHistory({
entityType: 'system',
@@ -160,16 +169,9 @@ router.put('/agents/:id', async (req, res, next) => {
try {
const [result] = await pool.query(
`UPDATE icon_scanner_agents
- SET name = ?, keywords = ?, sources = ?, excludes = ?, is_active = ?
+ SET name = ?, keywords = ?, alarm_keywords = ?, sources = ?, excludes = ?, is_active = ?
WHERE id = ?`,
- [
- agent.name,
- JSON.stringify(agent.keywords),
- JSON.stringify(agent.sources),
- JSON.stringify(agent.excludes),
- agent.is_active ? 1 : 0,
- Number(req.params.id),
- ]
+ [...agentValues(agent), Number(req.params.id)]
);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Suchagent nicht gefunden.' });
const [[row]] = await pool.query('SELECT * FROM icon_scanner_agents WHERE id = ?', [Number(req.params.id)]);
@@ -236,21 +238,37 @@ router.get('/matches', async (req, res, next) => {
where.push('(j.title LIKE ? OR j.company LIKE ? OR j.location LIKE ?)');
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
}
+ /*
+ * Alter der Anzeige (nicht des Fundes). Gesucht wird seit 29.09.2026 ohne
+ * Altersgrenze; die 21 Tage sind nur noch Filter. Anzeigen ohne Datum
+ * (manche Boersen nennen keins) fallen bei beiden Filtern heraus.
+ */
+ if (req.query.age === 'recent' || req.query.age === 'old') {
+ where.push(
+ req.query.age === 'recent'
+ ? 'j.published_at >= NOW() - INTERVAL ? DAY'
+ : 'j.published_at < NOW() - INTERVAL ? DAY'
+ );
+ params.push(AGE_DAYS);
+ }
+ if (req.query.alarm === '1') where.push('m.alarm = 1');
const order =
req.query.sort === 'score'
? 'm.score DESC, m.created_at DESC'
: req.query.sort === 'published'
? 'j.published_at IS NULL, j.published_at DESC'
- : // bekannte Firmen zuerst, dann neueste
- 'known DESC, m.created_at DESC';
+ : req.query.sort === 'oldest'
+ ? 'j.published_at IS NULL, j.published_at ASC'
+ : // bekannte Firmen zuerst, dann neueste
+ 'known DESC, m.created_at DESC';
const limit = Math.min(Math.max(Number(req.query.limit) || 300, 1), 1000);
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
try {
const [rows] = await pool.query(
- `SELECT m.id, m.score, m.hits, m.status, m.created_at,
+ `SELECT m.id, m.score, m.hits, m.status, m.alarm, m.created_at,
a.id AS agent_id, a.name AS agent_name,
j.id AS job_id, j.source, j.title, j.company, j.location, j.country,
j.employment_type, j.url, j.description, j.published_at,
@@ -276,6 +294,7 @@ router.get('/matches', async (req, res, next) => {
matches: rows.map((row) => ({
...row,
hits: parseList(row.hits),
+ alarm: Boolean(row.alarm),
known: Boolean(row.known),
is_customer: row.is_customer == null ? null : Boolean(row.is_customer),
})),
@@ -300,6 +319,43 @@ router.patch('/matches/:id', async (req, res, next) => {
}
});
+/* ------------------------------------------------------------------------
+ * Alarme - Treffer mit Alarm-Schlagwort, die noch niemand quittiert hat.
+ *
+ * Der AlarmWatcher der Oberflaeche fragt jede Minute, egal welche Ansicht
+ * offen ist, spielt einen Ton und zeigt ein Pop-up. Quittiert wird fuer alle:
+ * wer das Pop-up schliesst, nimmt es auch den Kollegen ab - sonst klingelte
+ * derselbe Fund bei jedem einzeln.
+ * ---------------------------------------------------------------------- */
+router.get('/alarms', async (req, res, next) => {
+ try {
+ const [rows] = await pool.query(
+ `SELECT m.id, m.score, m.hits, m.created_at, a.name AS agent_name,
+ j.source, j.title, j.company, j.location, j.url, j.published_at, j.company_id
+ FROM icon_scanner_matches m
+ JOIN icon_scanner_agents a ON a.id = m.agent_id
+ JOIN icon_scanner_jobs j ON j.id = m.job_id
+ WHERE m.alarm = 1 AND m.alarm_ack = 0 AND m.status <> 'verworfen'
+ ORDER BY m.id DESC
+ LIMIT 50`
+ );
+ res.json({ alarms: rows.map((row) => ({ ...row, hits: parseList(row.hits) })) });
+ } catch (err) {
+ next(err);
+ }
+});
+
+router.post('/alarms/ack', async (req, res, next) => {
+ const ids = (Array.isArray(req.body?.ids) ? req.body.ids : []).map(Number).filter(Number.isInteger).slice(0, 200);
+ if (ids.length === 0) return res.status(400).json({ error: 'Keine Treffer übergeben.' });
+ try {
+ await pool.query('UPDATE icon_scanner_matches SET alarm_ack = 1 WHERE id IN (?)', [ids]);
+ res.json({ ok: true });
+ } catch (err) {
+ next(err);
+ }
+});
+
/* ------------------------------------------------------------------------
* Jetzt scannen - laeuft im Hintergrund, die Oberflaeche fragt /overview ab.
* Ein Lauf dauert je nach Zahl der Schlagwoerter Minuten; darauf zu warten
diff --git a/server/src/scanner/matching.js b/server/src/scanner/matching.js
index a59cc28..c46f176 100644
--- a/server/src/scanner/matching.js
+++ b/server/src/scanner/matching.js
@@ -41,8 +41,23 @@ export function containsTerm(text, term) {
}
/**
- * @returns {{ score: number, hits: string[] } | null} null = ausgeschlossen
- * oder kein einziges Schlagwort gefunden
+ * Alle Suchbegriffe eines Agenten: Schlagwoerter plus Alarm-Schlagwoerter,
+ * ohne Doppelte (Gross/klein egal).
+ */
+export function agentTerms(agent) {
+ const seen = new Set();
+ return [...(agent.keywords ?? []), ...(agent.alarmKeywords ?? [])].filter((term) => {
+ const key = term.toLowerCase();
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+}
+
+/**
+ * @returns {{ score: number, hits: string[], alarm: boolean } | null}
+ * null = ausgeschlossen oder kein einziges Schlagwort gefunden.
+ * `alarm` = mindestens ein Alarm-Schlagwort steht in der Anzeige.
*/
export function scoreJob(agent, job) {
const title = job.title ?? '';
@@ -50,7 +65,7 @@ export function scoreJob(agent, job) {
if ((agent.excludes ?? []).some((term) => containsTerm(all, term))) return null;
- const keywords = agent.keywords ?? [];
+ const keywords = agentTerms(agent);
if (keywords.length === 0) return null;
let points = 0;
@@ -66,7 +81,12 @@ export function scoreJob(agent, job) {
}
if (hits.length === 0) return null;
- return { score: Math.round((100 * points) / (2 * keywords.length)), hits };
+ const alarmSet = new Set((agent.alarmKeywords ?? []).map((term) => term.toLowerCase()));
+ return {
+ score: Math.round((100 * points) / (2 * keywords.length)),
+ hits,
+ alarm: hits.some((hit) => alarmSet.has(hit.toLowerCase())),
+ };
}
/*
diff --git a/server/src/scanner/pipeline.js b/server/src/scanner/pipeline.js
index 6d18f33..6a9fd10 100644
--- a/server/src/scanner/pipeline.js
+++ b/server/src/scanner/pipeline.js
@@ -1,7 +1,7 @@
import { pool } from '../db.js';
import { SOURCES, sourceReady } from './sources.js';
-import { findCompany, normalizeCompany, scoreJob, stripQuotes } from './matching.js';
-import { loadScannerSecrets, loadScannerSettings, setLastScanAt } from './settings.js';
+import { agentTerms, findCompany, normalizeCompany, scoreJob, stripQuotes } from './matching.js';
+import { loadScannerSecrets, setLastScanAt } from './settings.js';
/*
* Ein Suchlauf.
@@ -9,7 +9,7 @@ import { loadScannerSecrets, loadScannerSettings, setLastScanAt } from './settin
* 1. aktive Agenten laden
* 2. je Boerse die Schlagwoerter aller Agenten zusammenfuehren - dasselbe
* Wort wird nur einmal abgefragt, egal wie viele Agenten es tragen
- * 3. Anzeigen abrufen, alles ueber der Altersgrenze verwerfen
+ * 3. Anzeigen abrufen - jeden Alters (das Dashboard filtert nach Alter)
* 4. Anzeigen speichern, Firma gegen das CRM abgleichen
* 5. jede Anzeige gegen jeden Agenten bewerten, der diese Boerse durchsucht
*
@@ -56,24 +56,22 @@ async function execute(trigger) {
const [runResult] = await pool.query('INSERT INTO icon_scanner_runs (trigger_kind) VALUES (?)', [trigger]);
const runId = runResult.insertId;
- const settings = await loadScannerSettings();
const secrets = await loadScannerSecrets();
const agents = await loadActiveAgents();
- // Boerse -> Schlagwoerter (ohne Doppelte, Gross/klein egal)
+ // Boerse -> Schlagwoerter inkl. Alarm-Schlagwoerter (ohne Doppelte)
const plan = new Map();
for (const agent of agents) {
for (const source of agent.sources) {
if (!SOURCES[source] || !sourceReady(source, secrets)) continue;
const words = plan.get(source) ?? new Map();
- for (const keyword of agent.keywords) words.set(keyword.toLowerCase(), keyword);
+ for (const keyword of agentTerms(agent)) words.set(keyword.toLowerCase(), keyword);
plan.set(source, words);
}
}
const perSource = {};
const jobs = new Map();
- const oldest = Date.now() - settings.maxAge * 24 * 3_600_000;
for (const [source, words] of plan) {
const stats = { ok: true, count: 0, errors: [] };
@@ -82,11 +80,10 @@ async function execute(trigger) {
if (!first) await sleep(REQUEST_GAP_MS);
first = false;
try {
- const found = await SOURCES[source].fetch(keyword, { maxAge: settings.maxAge, secrets });
+ // Seit 29.09.2026 ohne Altersgrenze: auch aeltere Anzeigen kommen
+ // herein, gefiltert wird im Dashboard ("Anzeigenalter").
+ const found = await SOURCES[source].fetch(keyword, { secrets });
for (const job of found) {
- // Ohne Datum bleibt die Anzeige drin: die Boersen sortieren bzw.
- // filtern selbst nach Alter, das fehlende Feld ist ein Parserluecke.
- if (job.publishedAt && job.publishedAt.getTime() < oldest) continue;
jobs.set(`${job.source}:${job.externalId}`, job);
stats.count += 1;
}
@@ -111,8 +108,8 @@ async function execute(trigger) {
const result = scoreJob(agent, job);
if (!result) continue;
const [insert] = await pool.query(
- `INSERT IGNORE INTO icon_scanner_matches (agent_id, job_id, score, hits) VALUES (?, ?, ?, ?)`,
- [agent.id, jobId, result.score, JSON.stringify(result.hits)]
+ `INSERT IGNORE INTO icon_scanner_matches (agent_id, job_id, score, hits, alarm) VALUES (?, ?, ?, ?, ?)`,
+ [agent.id, jobId, result.score, JSON.stringify(result.hits), result.alarm ? 1 : 0]
);
matchesNew += insert.affectedRows;
}
@@ -153,6 +150,7 @@ async function loadActiveAgents() {
return rows.map((row) => ({
id: row.id,
keywords: parseList(row.keywords),
+ alarmKeywords: parseList(row.alarm_keywords),
sources: parseList(row.sources),
excludes: parseList(row.excludes),
}));
diff --git a/server/src/scanner/settings.js b/server/src/scanner/settings.js
index 22683a3..0b6248c 100644
--- a/server/src/scanner/settings.js
+++ b/server/src/scanner/settings.js
@@ -5,7 +5,7 @@ import { getSetting, setSetting } from '../db.js';
*
* Alles liegt in icon_settings unter dem Praefix `scanner_`. Die Vorgaben
* entsprechen dem Wunsch vom 29.09.2026: stuendlich, Mo-Fr, 06-20 Uhr,
- * Anzeigen hoechstens 21 Tage alt.
+ * ohne Altersgrenze (seit 29.09.2026 - das Alter filtert das Dashboard).
*/
export const DEFAULTS = {
@@ -15,7 +15,6 @@ export const DEFAULTS = {
from: '06:00',
to: '20:00',
interval: 60,
- maxAge: 21,
};
/** Zugangsdaten der kostenlosen APIs - gehen nie im Klartext zum Browser. */
@@ -27,13 +26,12 @@ const SECRET_KEYS = {
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
export async function loadScannerSettings() {
- const [active, days, from, to, interval, maxAge] = await Promise.all([
+ const [active, days, from, to, interval] = await Promise.all([
getSetting('scanner_active'),
getSetting('scanner_days'),
getSetting('scanner_from'),
getSetting('scanner_to'),
getSetting('scanner_interval'),
- getSetting('scanner_max_age'),
]);
const parsedDays = (days ?? '')
@@ -47,7 +45,6 @@ export async function loadScannerSettings() {
from: TIME.test(from ?? '') ? from : DEFAULTS.from,
to: TIME.test(to ?? '') ? to : DEFAULTS.to,
interval: clampInt(interval, 15, 24 * 60, DEFAULTS.interval),
- maxAge: clampInt(maxAge, 1, 90, DEFAULTS.maxAge),
};
}
@@ -69,7 +66,6 @@ export async function saveScannerSettings(input) {
}
if (next.from >= next.to) throw badRequest('Die Startzeit muss vor der Endzeit liegen.');
if (input.interval !== undefined) next.interval = clampInt(input.interval, 15, 24 * 60, current.interval);
- if (input.maxAge !== undefined) next.maxAge = clampInt(input.maxAge, 1, 90, current.maxAge);
await Promise.all([
setSetting('scanner_active', String(next.active)),
@@ -77,7 +73,6 @@ export async function saveScannerSettings(input) {
setSetting('scanner_from', next.from),
setSetting('scanner_to', next.to),
setSetting('scanner_interval', String(next.interval)),
- setSetting('scanner_max_age', String(next.maxAge)),
]);
// Schluessel: undefined = unveraendert, '' = entfernen
diff --git a/server/src/scanner/sources.js b/server/src/scanner/sources.js
index fb777f4..dff79fd 100644
--- a/server/src/scanner/sources.js
+++ b/server/src/scanner/sources.js
@@ -129,10 +129,10 @@ const slug = (keyword) =>
* Bundesagentur fuer Arbeit - offizielle Jobsuche-API (JSON, ohne Anmeldung;
* der Schluessel "jobboerse-jobsuche" ist der oeffentliche der Jobsuche-App).
* ---------------------------------------------------------------------- */
-async function fetchBa(keyword, { maxAge }) {
+async function fetchBa(keyword) {
const url =
'https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v6/jobs?' +
- new URLSearchParams({ was: keyword, veroeffentlichtseit: String(maxAge), size: '100', page: '1' });
+ new URLSearchParams({ was: keyword, size: '100', page: '1' });
const data = await request(url, { json: true, headers: { 'X-API-Key': 'jobboerse-jobsuche' } });
const list = data?.ergebnisliste ?? data?.stellenangebote ?? [];
@@ -264,7 +264,7 @@ async function fetchKarriere(keyword) {
* Adzuna - kostenloser API-Schluessel (developer.adzuna.com), sammelt Anzeigen
* vieler Boersen fuer DE, AT und CH. Die einzige Quelle fuer die Schweiz.
* ---------------------------------------------------------------------- */
-async function fetchAdzuna(keyword, { maxAge, secrets }) {
+async function fetchAdzuna(keyword, { secrets }) {
const jobs = [];
for (const country of ['de', 'at', 'ch']) {
const url =
@@ -273,7 +273,6 @@ async function fetchAdzuna(keyword, { maxAge, secrets }) {
app_id: secrets.adzunaId,
app_key: secrets.adzunaKey,
what_phrase: keyword,
- max_days_old: String(maxAge),
results_per_page: '50',
sort_by: 'date',
'content-type': 'application/json',
diff --git a/server/src/schema.sql b/server/src/schema.sql
index bba6f32..b47a7b4 100644
--- a/server/src/schema.sql
+++ b/server/src/schema.sql
@@ -1,537 +1,543 @@
--- ============================================================================
--- ICON CRM - Datenbankschema (MariaDB, Datenbank icon_CRM)
---
--- Idempotent: die Datei laeuft bei jedem Backend-Start durch. Jedes Statement
--- muss deshalb ein IF NOT EXISTS tragen oder sonst wiederholbar sein.
--- Spaltenaenderungen an bestehenden Tabellen gehoeren nicht hierher, sondern in
--- die Migrationen in db.js - CREATE TABLE IF NOT EXISTS fasst eine vorhandene
--- Tabelle nicht an.
---
--- Grundgedanke des Modells (aus der Featureliste vom 14.09.2026):
--- * "Experten" heissen jetzt Kontakte und umfassen auch die Ansprechpartner
--- der Kunden - eine Tabelle, unterschieden ueber `kind`.
--- * "Kunden" heissen jetzt Unternehmen. Ein Kunde ist ein Unternehmen mit
--- is_customer = 1; der Umschalter in der Oberflaeche filtert nur darauf.
--- * Jede Aenderung landet in icon_history. Wer wann was getan hat, steht
--- damit an genau einer Stelle - je Kontakt, je Unternehmen und global.
--- ============================================================================
-
--- ---------------------------------------------------------------------------
--- Benutzer
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_users (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- username VARCHAR(60) NOT NULL,
- email VARCHAR(190) NULL,
- name VARCHAR(190) NOT NULL,
- role ENUM('admin','user') NOT NULL DEFAULT 'user',
- password_hash VARCHAR(255) NOT NULL,
- -- Persoenliche Farbe: traegt Initialen-Avatare, Historie und Netzwerkknoten.
- color VARCHAR(9) NULL,
- -- Foto als Data-URL, im Browser auf 256 px vorkomprimiert.
- avatar MEDIUMTEXT NULL,
- theme VARCHAR(40) NULL,
- theme_mode ENUM('light','dark') NULL,
- is_active TINYINT(1) NOT NULL DEFAULT 1,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- UNIQUE KEY uq_icon_users_username (username),
- UNIQUE KEY uq_icon_users_email (email)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Unternehmen (fruehere "Kunden")
---
--- `is_customer` unterscheidet unsere Kunden von allen uebrigen bekannten
--- Unternehmen; `customer_state` trennt darin aktiv von alumni. Ein Unternehmen
--- ohne Kundenhaken ist trotzdem vollwertig - dort haengen Werdegaenge, Leads
--- und die Unternehmenskarte dran.
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_companies (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- name VARCHAR(190) NOT NULL,
- -- Dachgesellschaft, z. B. "Finanz Informatik" ueber FI Hamburg/Frankfurt.
- group_name VARCHAR(190) NULL,
- industry VARCHAR(190) NULL,
- employees INT UNSIGNED NULL,
- website VARCHAR(300) NULL,
- address TEXT NULL,
- logo MEDIUMTEXT NULL,
- -- 0 = nicht bewertet, 1..5 Sterne: wie interessant ist das Unternehmen.
- rating TINYINT UNSIGNED NOT NULL DEFAULT 0,
- is_customer TINYINT(1) NOT NULL DEFAULT 0,
- customer_state ENUM('aktiv','alumni') NOT NULL DEFAULT 'aktiv',
- freelancers ENUM('unbekannt','ja','nein') NOT NULL DEFAULT 'unbekannt',
- notes MEDIUMTEXT NULL,
- created_by INT UNSIGNED NULL,
- owner_id INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- KEY idx_icon_companies_name (name),
- KEY idx_icon_companies_group (group_name),
- KEY idx_icon_companies_customer (is_customer, customer_state),
- CONSTRAINT fk_icon_companies_creator FOREIGN KEY (created_by)
- REFERENCES icon_users (id) ON DELETE SET NULL,
- CONSTRAINT fk_icon_companies_owner FOREIGN KEY (owner_id)
- REFERENCES icon_users (id) ON DELETE SET NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Kontakte (fruehere "Experten" plus Ansprechpartner der Unternehmen)
---
--- Der Verfuegbarkeitsstatus ist bewusst entfallen (Featureliste 14.09.2026).
--- `status` beschreibt nur noch den Stand der Zusammenarbeit.
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_contacts (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- -- Akademischer Titel als Freitext ("Dr.", "Prof. Dr."). Bewusst keine
- -- Auswahlliste: die Schreibweisen sind zu vielfaeltig, um sie zu pflegen.
- title VARCHAR(60) NULL,
- first_name VARCHAR(120) NULL,
- last_name VARCHAR(120) NOT NULL,
- position VARCHAR(190) NULL,
- email VARCHAR(190) NULL,
- phone VARCHAR(80) NULL,
- linkedin VARCHAR(300) NULL,
- avatar MEDIUMTEXT NULL,
- -- Aktueller Arbeitgeber. Die Historie der frueheren steht in
- -- icon_employments - daraus speist sich der Zweig "Verlassen".
- company_id INT UNSIGNED NULL,
- kind ENUM('Experte','Ansprechpartner (Kunde)','Lead') NOT NULL DEFAULT 'Experte',
- status ENUM('Interessiert','Interview','Gebucht','Inaktiv') NOT NULL DEFAULT 'Interessiert',
- -- Woher kommt der Kontakt (LinkedIn, Empfehlung, Portal, ...).
- lead_source VARCHAR(190) NULL,
- -- Featureliste: "persoenlicher Kontakt" oder "noch kein Kontakt gehabt".
- contact_state ENUM('unbekannt','persoenlich','kein Kontakt') NOT NULL DEFAULT 'unbekannt',
- freelancers ENUM('unbekannt','ja','nein') NOT NULL DEFAULT 'unbekannt',
- bio MEDIUMTEXT NULL,
- -- Kommagetrennte Schlagworte; die Oberflaeche zerlegt sie in Chips.
- tags VARCHAR(1000) NULL,
- rate_onsite DECIMAL(8,2) NULL,
- rate_remote DECIMAL(8,2) NULL,
- cv_url VARCHAR(500) NULL,
- cv_file VARCHAR(255) NULL,
- created_by INT UNSIGNED NULL,
- owner_id INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- KEY idx_icon_contacts_name (last_name, first_name),
- KEY idx_icon_contacts_company (company_id),
- KEY idx_icon_contacts_kind (kind),
- KEY idx_icon_contacts_status (status),
- CONSTRAINT fk_icon_contacts_company FOREIGN KEY (company_id)
- REFERENCES icon_companies (id) ON DELETE SET NULL,
- CONSTRAINT fk_icon_contacts_creator FOREIGN KEY (created_by)
- REFERENCES icon_users (id) ON DELETE SET NULL,
- CONSTRAINT fk_icon_contacts_owner FOREIGN KEY (owner_id)
- REFERENCES icon_users (id) ON DELETE SET NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Werdegang: welcher Kontakt war wann bei welchem Unternehmen
---
--- Traegt zwei Dinge aus der Featureliste: die automatische Liste "letzter
--- Arbeitgeber (Branche & Unternehmen)" und den Zweig "Verlassen" im Stammbaum
--- eines Unternehmens (is_current = 0).
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_employments (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- contact_id INT UNSIGNED NOT NULL,
- company_id INT UNSIGNED NOT NULL,
- position VARCHAR(190) NULL,
- started_on DATE NULL,
- ended_on DATE NULL,
- is_current TINYINT(1) NOT NULL DEFAULT 0,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- KEY idx_icon_employments_contact (contact_id),
- KEY idx_icon_employments_company (company_id, is_current),
- CONSTRAINT fk_icon_employments_contact FOREIGN KEY (contact_id)
- REFERENCES icon_contacts (id) ON DELETE CASCADE,
- CONSTRAINT fk_icon_employments_company FOREIGN KEY (company_id)
- REFERENCES icon_companies (id) ON DELETE CASCADE
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Interviews: "Hatten wir schon einmal ein Interview, und falls ja - wann,
--- mit wem?" Eigene Tabelle statt Freitext, damit danach gefiltert werden kann.
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_interviews (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- contact_id INT UNSIGNED NULL,
- company_id INT UNSIGNED NULL,
- -- Wer von uns das Gespraech gefuehrt hat.
- user_id INT UNSIGNED NULL,
- held_on DATE NOT NULL,
- notes MEDIUMTEXT NULL,
- created_by INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- KEY idx_icon_interviews_contact (contact_id, held_on),
- KEY idx_icon_interviews_company (company_id, held_on),
- CONSTRAINT fk_icon_interviews_contact FOREIGN KEY (contact_id)
- REFERENCES icon_contacts (id) ON DELETE CASCADE,
- CONSTRAINT fk_icon_interviews_company FOREIGN KEY (company_id)
- REFERENCES icon_companies (id) ON DELETE CASCADE,
- CONSTRAINT fk_icon_interviews_user FOREIGN KEY (user_id)
- REFERENCES icon_users (id) ON DELETE SET NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Historie / Aktivitaetsprotokoll
---
--- Eine Tabelle fuer alles: Systemereignisse (angelegt, geaendert, geloescht)
--- und von Hand erfasste Notizen. `entity_type`/`entity_id` sagen, an welcher
--- Karte der Eintrag haengt; `user_id` immer, wer es war.
---
--- Absichtlich keine Fremdschluessel auf die Ziel-Tabellen: die Historie soll
--- einen geloeschten Kontakt ueberleben. Deshalb steht mit `entity_label` auch
--- der Name zum Zeitpunkt des Eintrags mit drin.
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_history (
- id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- entity_type ENUM('contact','company','tender','project','lead','user','system') NOT NULL,
- entity_id INT UNSIGNED NULL,
- entity_label VARCHAR(300) NULL,
- -- created | updated | deleted | note | mention | interview | stage | ...
- action VARCHAR(40) NOT NULL DEFAULT 'note',
- -- Anzeigetext, darf Mention-Token @[Name](contact:12) enthalten.
- text MEDIUMTEXT NULL,
- -- Feldaenderungen als JSON: { feld: [vorher, nachher], ... }
- changes MEDIUMTEXT NULL,
- user_id INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- KEY idx_icon_history_entity (entity_type, entity_id, created_at),
- KEY idx_icon_history_created (created_at),
- CONSTRAINT fk_icon_history_user FOREIGN KEY (user_id)
- REFERENCES icon_users (id) ON DELETE SET NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Erwaehnungen (@-Mentions)
---
--- Der Token steht im Text; diese Tabelle ist der Index dazu. Damit beantwortet
--- eine Abfrage "wo wurde dieses Unternehmen ueberall genannt?", ohne jeden
--- Freitext der Datenbank durchsuchen zu muessen.
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_mentions (
- id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- -- Wo die Erwaehnung steht.
- source_type ENUM('history','contact','company','tender','project','lead') NOT NULL,
- source_id BIGINT UNSIGNED NOT NULL,
- -- Wer bzw. was erwaehnt wurde.
- target_type ENUM('contact','company','user') NOT NULL,
- target_id INT UNSIGNED NOT NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE KEY uq_icon_mentions (source_type, source_id, target_type, target_id),
- KEY idx_icon_mentions_target (target_type, target_id, created_at)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Netzwerk: wer kennt wen
---
--- Das Paar wird aufsteigend gespeichert (a < b), der Unique-Key verhindert
--- damit Duplikate in beide Richtungen.
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_connections (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- contact_a INT UNSIGNED NOT NULL,
- contact_b INT UNSIGNED NOT NULL,
- kind VARCHAR(60) NOT NULL DEFAULT 'Kontakt',
- strength TINYINT UNSIGNED NOT NULL DEFAULT 1,
- created_by INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE KEY uq_icon_connections (contact_a, contact_b),
- CONSTRAINT fk_icon_connections_a FOREIGN KEY (contact_a)
- REFERENCES icon_contacts (id) ON DELETE CASCADE,
- CONSTRAINT fk_icon_connections_b FOREIGN KEY (contact_b)
- REFERENCES icon_contacts (id) ON DELETE CASCADE
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Ausschreibungen
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_tenders (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- title VARCHAR(300) NOT NULL,
- source VARCHAR(190) NULL,
- link VARCHAR(600) NULL,
- company_id INT UNSIGNED NULL,
- status ENUM('Aktiv','Inaktiv','Ausgelaufen','Archiviert') NOT NULL DEFAULT 'Aktiv',
- stage ENUM('Ausschreibung','Kontakte sichten','Interview','Kunden Interview',
- 'Vertragsgestaltung','Abschluss','Geschlossen') NOT NULL DEFAULT 'Ausschreibung',
- online TINYINT(1) NOT NULL DEFAULT 1,
- details MEDIUMTEXT NULL,
- notes MEDIUMTEXT NULL,
- deadline DATE NULL,
- created_by INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- KEY idx_icon_tenders_stage (stage),
- KEY idx_icon_tenders_status (status),
- CONSTRAINT fk_icon_tenders_company FOREIGN KEY (company_id)
- REFERENCES icon_companies (id) ON DELETE SET NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
-CREATE TABLE IF NOT EXISTS icon_tender_contacts (
- tender_id INT UNSIGNED NOT NULL,
- contact_id INT UNSIGNED NOT NULL,
- role VARCHAR(120) NULL,
- PRIMARY KEY (tender_id, contact_id),
- CONSTRAINT fk_icon_tc_tender FOREIGN KEY (tender_id)
- REFERENCES icon_tenders (id) ON DELETE CASCADE,
- CONSTRAINT fk_icon_tc_contact FOREIGN KEY (contact_id)
- REFERENCES icon_contacts (id) ON DELETE CASCADE
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Laufende Projekte
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_projects (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- title VARCHAR(300) NOT NULL,
- company_id INT UNSIGNED NULL,
- contact_id INT UNSIGNED NULL,
- started_on DATE NULL,
- ended_on DATE NULL,
- status ENUM('Aktiv','Nicht aktiv','Abgeschlossen') NOT NULL DEFAULT 'Aktiv',
- rate DECIMAL(8,2) NULL,
- notes MEDIUMTEXT NULL,
- created_by INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- KEY idx_icon_projects_status (status),
- CONSTRAINT fk_icon_projects_company FOREIGN KEY (company_id)
- REFERENCES icon_companies (id) ON DELETE SET NULL,
- CONSTRAINT fk_icon_projects_contact FOREIGN KEY (contact_id)
- REFERENCES icon_contacts (id) ON DELETE SET NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Lead-Pipeline (Kanban)
---
--- Schema steht schon; die Oberflaeche dazu kommt in der zweiten Ausbaustufe.
--- `remind_at` traegt die Erinnerungsfunktion, `reminder_days` das Intervall.
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_leads (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- title VARCHAR(300) NOT NULL,
- company_id INT UNSIGNED NULL,
- contact_id INT UNSIGNED NULL,
- stage ENUM('Fuer Spaeter','Neuer Lead','Erstkontakt','1. Gespraech',
- 'Followup geplant','Followup Gespraech','Closed','Absage')
- NOT NULL DEFAULT 'Neuer Lead',
- contact_state ENUM('unbekannt','persoenlich','kein Kontakt') NOT NULL DEFAULT 'unbekannt',
- archived TINYINT(1) NOT NULL DEFAULT 0,
- position INT NOT NULL DEFAULT 0,
- notes MEDIUMTEXT NULL,
- -- Zeitpunkt der naechsten Erinnerung; NULL = keine.
- remind_at DATETIME NULL,
- reminder_days INT UNSIGNED NULL,
- last_contact_at DATETIME NULL,
- owner_id INT UNSIGNED NULL,
- created_by INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- KEY idx_icon_leads_stage (stage, archived, position),
- KEY idx_icon_leads_remind (remind_at),
- CONSTRAINT fk_icon_leads_company FOREIGN KEY (company_id)
- REFERENCES icon_companies (id) ON DELETE SET NULL,
- CONSTRAINT fk_icon_leads_contact FOREIGN KEY (contact_id)
- REFERENCES icon_contacts (id) ON DELETE SET NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Suspects - die Stufe vor dem Lead (29.09.2026)
---
--- Personen, die interessant sein koennten, als schlichte Tabelle: Name,
--- Funktion und Unternehmen sind bewusst Freitext, nicht Verweise auf Kontakte
--- und Unternehmen - ein Suspect soll in Sekunden erfasst sein. Erst beim
--- Schritt zum Lead wird gegen die Stammdaten abgeglichen.
---
--- `remind_at` ist der "naechste Impuls": dann erinnert das CRM daran, die
--- Person erneut anzusprechen. `lead_id` zeigt nach der Uebergabe auf den Lead;
--- der Suspect selbst wandert dann ins Archiv.
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_suspects (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- name VARCHAR(190) NOT NULL,
- role VARCHAR(190) NULL,
- company VARCHAR(190) NULL,
- email VARCHAR(190) NULL,
- linkedin VARCHAR(500) NULL,
- website VARCHAR(500) NULL,
- notes TEXT NULL,
- last_contact_on DATE NULL,
- remind_at DATETIME NULL,
- archived TINYINT(1) NOT NULL DEFAULT 0,
- lead_id INT UNSIGNED NULL,
- created_by INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- KEY idx_icon_suspects_archived (archived, id),
- KEY idx_icon_suspects_remind (remind_at),
- CONSTRAINT fk_icon_suspects_lead FOREIGN KEY (lead_id)
- REFERENCES icon_leads (id) ON DELETE SET NULL,
- CONSTRAINT fk_icon_suspects_creator FOREIGN KEY (created_by)
- REFERENCES icon_users (id) ON DELETE SET NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
-CREATE TABLE IF NOT EXISTS icon_lead_todos (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- lead_id INT UNSIGNED NOT NULL,
- text VARCHAR(500) NOT NULL,
- done TINYINT(1) NOT NULL DEFAULT 0,
- due_on DATE NULL,
- position INT NOT NULL DEFAULT 0,
- created_by INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- KEY idx_icon_lead_todos_lead (lead_id, position),
- CONSTRAINT fk_icon_lead_todos_lead FOREIGN KEY (lead_id)
- REFERENCES icon_leads (id) ON DELETE CASCADE
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- CV-Import
---
--- Der Ablauf aus der Featureliste als drei Ordner in einer Tabelle:
--- eingang hochgeladen, noch nicht ausgelesen
--- screening von der KI ausgelesen, wartet auf Ja oder Nein
--- auswahl uebernommen - der Kontakt ist angelegt, der CV haengt dran
--- verworfen abgelehnt; die Datei bleibt, bis sie jemand loescht
---
--- `extracted` haelt die Ausgabe des Modells als JSON. Sie bleibt auch nach der
--- Uebernahme stehen: so laesst sich spaeter nachvollziehen, was die KI gelesen
--- hat und was ein Mensch daran geaendert hat.
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_cv_imports (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- filename VARCHAR(255) NOT NULL,
- original_name VARCHAR(255) NOT NULL,
- mime VARCHAR(120) NULL,
- size_bytes INT UNSIGNED NOT NULL DEFAULT 0,
- status ENUM('eingang','screening','auswahl','verworfen') NOT NULL DEFAULT 'eingang',
- extracted MEDIUMTEXT NULL,
- -- Das im Lebenslauf gefundene Portrait, unveraendert als Data-URL. Es steht
- -- hier nur zwischen Auslesen und Uebernehmen; beim Anlegen des Kontakts geht
- -- es verkleinert an icon_contacts.avatar und wird hier geleert.
- photo MEDIUMTEXT NULL,
- error VARCHAR(500) NULL,
- contact_id INT UNSIGNED NULL,
- uploaded_by INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- processed_at TIMESTAMP NULL DEFAULT NULL,
- KEY idx_icon_cv_status (status, created_at),
- CONSTRAINT fk_icon_cv_contact FOREIGN KEY (contact_id)
- REFERENCES icon_contacts (id) ON DELETE SET NULL,
- CONSTRAINT fk_icon_cv_user FOREIGN KEY (uploaded_by)
- REFERENCES icon_users (id) ON DELETE SET NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Stammdaten: gepflegte Auswahllisten
---
--- Branche und Kontaktart waren Freitext bzw. ein festes ENUM. Beides wird in
--- der Praxis erweitert - eine neue Branche, eine neue Art von Kontakt - und
--- beides soll ohne Codeaenderung gehen. Die Werte stehen deshalb hier und
--- speisen die Auswahlfelder der Oberflaeche.
---
--- `field` ist bewusst kein ENUM: eine weitere Liste soll keine Migration
--- kosten.
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_options (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- field VARCHAR(40) NOT NULL,
- value VARCHAR(190) NOT NULL,
- position INT NOT NULL DEFAULT 0,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE KEY uq_icon_options (field, value),
- KEY idx_icon_options_field (field, position)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Einstellungen (Schluessel/Wert) - auch fuer einmalige Migrationsmerker
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_settings (
- key_name VARCHAR(100) NOT NULL PRIMARY KEY,
- value MEDIUMTEXT NULL,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- ---------------------------------------------------------------------------
--- Scanner: Stellenanzeigen aus Jobbörsen (DACH, höchstens 21 Tage alt)
---
--- Ein Suchagent ist eine Liste von Schlagwörtern plus der Börsen, in denen
--- gesucht wird, plus Ausschlussbegriffen. Jede Anzeige liegt genau einmal in
--- icon_scanner_jobs (eindeutig je Börse und deren Kennung); welcher Agent sie
--- mit welcher Güte gefunden hat, steht in icon_scanner_matches.
---
--- Bewertet wird nicht per KI, sondern per Zählung: je mehr Schlagwörter eines
--- Agenten in Titel und Text vorkommen, desto höher der Treffer.
--- ---------------------------------------------------------------------------
-CREATE TABLE IF NOT EXISTS icon_scanner_agents (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- name VARCHAR(190) NOT NULL,
- -- JSON-Arrays: ["SAP FI", "Interim CFO"], ["ba", "stepstone"], ["Praktikum"]
- keywords TEXT NOT NULL,
- sources TEXT NOT NULL,
- excludes TEXT NULL,
- is_active TINYINT(1) NOT NULL DEFAULT 1,
- created_by INT UNSIGNED NULL,
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- CONSTRAINT fk_icon_scanner_agents_creator FOREIGN KEY (created_by)
- REFERENCES icon_users (id) ON DELETE SET NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
-CREATE TABLE IF NOT EXISTS icon_scanner_jobs (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- source VARCHAR(30) NOT NULL,
- external_id VARCHAR(190) NOT NULL,
- title VARCHAR(500) NOT NULL,
- company VARCHAR(300) NULL,
- -- Treffer im CRM: Unternehmen mit gleichem (normalisiertem) Namen.
- company_id INT UNSIGNED NULL,
- location VARCHAR(300) NULL,
- country CHAR(2) NULL,
- employment_type VARCHAR(60) NULL,
- url VARCHAR(1000) NOT NULL,
- description MEDIUMTEXT NULL,
- published_at DATETIME NULL,
- first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- UNIQUE KEY uq_icon_scanner_jobs (source, external_id),
- KEY idx_icon_scanner_jobs_published (published_at),
- KEY idx_icon_scanner_jobs_company (company_id),
- CONSTRAINT fk_icon_scanner_jobs_company FOREIGN KEY (company_id)
- REFERENCES icon_companies (id) ON DELETE SET NULL
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
-CREATE TABLE IF NOT EXISTS icon_scanner_matches (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- agent_id INT UNSIGNED NOT NULL,
- job_id INT UNSIGNED NOT NULL,
- score TINYINT UNSIGNED NOT NULL DEFAULT 0,
- -- JSON-Array der gefundenen Schlagwörter
- hits TEXT NULL,
- status ENUM('neu','gesehen','in_bearbeitung','verworfen') NOT NULL DEFAULT 'neu',
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
- UNIQUE KEY uq_icon_scanner_matches (agent_id, job_id),
- KEY idx_icon_scanner_matches_created (created_at),
- CONSTRAINT fk_icon_scanner_matches_agent FOREIGN KEY (agent_id)
- REFERENCES icon_scanner_agents (id) ON DELETE CASCADE,
- CONSTRAINT fk_icon_scanner_matches_job FOREIGN KEY (job_id)
- REFERENCES icon_scanner_jobs (id) ON DELETE CASCADE
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
--- Protokoll der Läufe: wann, wodurch ausgelöst, was je Börse herauskam.
-CREATE TABLE IF NOT EXISTS icon_scanner_runs (
- id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
- trigger_kind ENUM('auto','manual') NOT NULL,
- started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
- finished_at TIMESTAMP NULL,
- jobs_found INT UNSIGNED NOT NULL DEFAULT 0,
- matches_new INT UNSIGNED NOT NULL DEFAULT 0,
- -- JSON: { ba: { ok: true, count: 12 }, stepstone: { ok: false, error: "..." } }
- per_source TEXT NULL,
- KEY idx_icon_scanner_runs_started (started_at)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+-- ============================================================================
+-- ICON CRM - Datenbankschema (MariaDB, Datenbank icon_CRM)
+--
+-- Idempotent: die Datei laeuft bei jedem Backend-Start durch. Jedes Statement
+-- muss deshalb ein IF NOT EXISTS tragen oder sonst wiederholbar sein.
+-- Spaltenaenderungen an bestehenden Tabellen gehoeren nicht hierher, sondern in
+-- die Migrationen in db.js - CREATE TABLE IF NOT EXISTS fasst eine vorhandene
+-- Tabelle nicht an.
+--
+-- Grundgedanke des Modells (aus der Featureliste vom 14.09.2026):
+-- * "Experten" heissen jetzt Kontakte und umfassen auch die Ansprechpartner
+-- der Kunden - eine Tabelle, unterschieden ueber `kind`.
+-- * "Kunden" heissen jetzt Unternehmen. Ein Kunde ist ein Unternehmen mit
+-- is_customer = 1; der Umschalter in der Oberflaeche filtert nur darauf.
+-- * Jede Aenderung landet in icon_history. Wer wann was getan hat, steht
+-- damit an genau einer Stelle - je Kontakt, je Unternehmen und global.
+-- ============================================================================
+
+-- ---------------------------------------------------------------------------
+-- Benutzer
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_users (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ username VARCHAR(60) NOT NULL,
+ email VARCHAR(190) NULL,
+ name VARCHAR(190) NOT NULL,
+ role ENUM('admin','user') NOT NULL DEFAULT 'user',
+ password_hash VARCHAR(255) NOT NULL,
+ -- Persoenliche Farbe: traegt Initialen-Avatare, Historie und Netzwerkknoten.
+ color VARCHAR(9) NULL,
+ -- Foto als Data-URL, im Browser auf 256 px vorkomprimiert.
+ avatar MEDIUMTEXT NULL,
+ theme VARCHAR(40) NULL,
+ theme_mode ENUM('light','dark') NULL,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_icon_users_username (username),
+ UNIQUE KEY uq_icon_users_email (email)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Unternehmen (fruehere "Kunden")
+--
+-- `is_customer` unterscheidet unsere Kunden von allen uebrigen bekannten
+-- Unternehmen; `customer_state` trennt darin aktiv von alumni. Ein Unternehmen
+-- ohne Kundenhaken ist trotzdem vollwertig - dort haengen Werdegaenge, Leads
+-- und die Unternehmenskarte dran.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_companies (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(190) NOT NULL,
+ -- Dachgesellschaft, z. B. "Finanz Informatik" ueber FI Hamburg/Frankfurt.
+ group_name VARCHAR(190) NULL,
+ industry VARCHAR(190) NULL,
+ employees INT UNSIGNED NULL,
+ website VARCHAR(300) NULL,
+ address TEXT NULL,
+ logo MEDIUMTEXT NULL,
+ -- 0 = nicht bewertet, 1..5 Sterne: wie interessant ist das Unternehmen.
+ rating TINYINT UNSIGNED NOT NULL DEFAULT 0,
+ is_customer TINYINT(1) NOT NULL DEFAULT 0,
+ customer_state ENUM('aktiv','alumni') NOT NULL DEFAULT 'aktiv',
+ freelancers ENUM('unbekannt','ja','nein') NOT NULL DEFAULT 'unbekannt',
+ notes MEDIUMTEXT NULL,
+ created_by INT UNSIGNED NULL,
+ owner_id INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ KEY idx_icon_companies_name (name),
+ KEY idx_icon_companies_group (group_name),
+ KEY idx_icon_companies_customer (is_customer, customer_state),
+ CONSTRAINT fk_icon_companies_creator FOREIGN KEY (created_by)
+ REFERENCES icon_users (id) ON DELETE SET NULL,
+ CONSTRAINT fk_icon_companies_owner FOREIGN KEY (owner_id)
+ REFERENCES icon_users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Kontakte (fruehere "Experten" plus Ansprechpartner der Unternehmen)
+--
+-- Der Verfuegbarkeitsstatus ist bewusst entfallen (Featureliste 14.09.2026).
+-- `status` beschreibt nur noch den Stand der Zusammenarbeit.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_contacts (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ -- Akademischer Titel als Freitext ("Dr.", "Prof. Dr."). Bewusst keine
+ -- Auswahlliste: die Schreibweisen sind zu vielfaeltig, um sie zu pflegen.
+ title VARCHAR(60) NULL,
+ first_name VARCHAR(120) NULL,
+ last_name VARCHAR(120) NOT NULL,
+ position VARCHAR(190) NULL,
+ email VARCHAR(190) NULL,
+ phone VARCHAR(80) NULL,
+ linkedin VARCHAR(300) NULL,
+ avatar MEDIUMTEXT NULL,
+ -- Aktueller Arbeitgeber. Die Historie der frueheren steht in
+ -- icon_employments - daraus speist sich der Zweig "Verlassen".
+ company_id INT UNSIGNED NULL,
+ kind ENUM('Experte','Ansprechpartner (Kunde)','Lead') NOT NULL DEFAULT 'Experte',
+ status ENUM('Interessiert','Interview','Gebucht','Inaktiv') NOT NULL DEFAULT 'Interessiert',
+ -- Woher kommt der Kontakt (LinkedIn, Empfehlung, Portal, ...).
+ lead_source VARCHAR(190) NULL,
+ -- Featureliste: "persoenlicher Kontakt" oder "noch kein Kontakt gehabt".
+ contact_state ENUM('unbekannt','persoenlich','kein Kontakt') NOT NULL DEFAULT 'unbekannt',
+ freelancers ENUM('unbekannt','ja','nein') NOT NULL DEFAULT 'unbekannt',
+ bio MEDIUMTEXT NULL,
+ -- Kommagetrennte Schlagworte; die Oberflaeche zerlegt sie in Chips.
+ tags VARCHAR(1000) NULL,
+ rate_onsite DECIMAL(8,2) NULL,
+ rate_remote DECIMAL(8,2) NULL,
+ cv_url VARCHAR(500) NULL,
+ cv_file VARCHAR(255) NULL,
+ created_by INT UNSIGNED NULL,
+ owner_id INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ KEY idx_icon_contacts_name (last_name, first_name),
+ KEY idx_icon_contacts_company (company_id),
+ KEY idx_icon_contacts_kind (kind),
+ KEY idx_icon_contacts_status (status),
+ CONSTRAINT fk_icon_contacts_company FOREIGN KEY (company_id)
+ REFERENCES icon_companies (id) ON DELETE SET NULL,
+ CONSTRAINT fk_icon_contacts_creator FOREIGN KEY (created_by)
+ REFERENCES icon_users (id) ON DELETE SET NULL,
+ CONSTRAINT fk_icon_contacts_owner FOREIGN KEY (owner_id)
+ REFERENCES icon_users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Werdegang: welcher Kontakt war wann bei welchem Unternehmen
+--
+-- Traegt zwei Dinge aus der Featureliste: die automatische Liste "letzter
+-- Arbeitgeber (Branche & Unternehmen)" und den Zweig "Verlassen" im Stammbaum
+-- eines Unternehmens (is_current = 0).
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_employments (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ contact_id INT UNSIGNED NOT NULL,
+ company_id INT UNSIGNED NOT NULL,
+ position VARCHAR(190) NULL,
+ started_on DATE NULL,
+ ended_on DATE NULL,
+ is_current TINYINT(1) NOT NULL DEFAULT 0,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_icon_employments_contact (contact_id),
+ KEY idx_icon_employments_company (company_id, is_current),
+ CONSTRAINT fk_icon_employments_contact FOREIGN KEY (contact_id)
+ REFERENCES icon_contacts (id) ON DELETE CASCADE,
+ CONSTRAINT fk_icon_employments_company FOREIGN KEY (company_id)
+ REFERENCES icon_companies (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Interviews: "Hatten wir schon einmal ein Interview, und falls ja - wann,
+-- mit wem?" Eigene Tabelle statt Freitext, damit danach gefiltert werden kann.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_interviews (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ contact_id INT UNSIGNED NULL,
+ company_id INT UNSIGNED NULL,
+ -- Wer von uns das Gespraech gefuehrt hat.
+ user_id INT UNSIGNED NULL,
+ held_on DATE NOT NULL,
+ notes MEDIUMTEXT NULL,
+ created_by INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_icon_interviews_contact (contact_id, held_on),
+ KEY idx_icon_interviews_company (company_id, held_on),
+ CONSTRAINT fk_icon_interviews_contact FOREIGN KEY (contact_id)
+ REFERENCES icon_contacts (id) ON DELETE CASCADE,
+ CONSTRAINT fk_icon_interviews_company FOREIGN KEY (company_id)
+ REFERENCES icon_companies (id) ON DELETE CASCADE,
+ CONSTRAINT fk_icon_interviews_user FOREIGN KEY (user_id)
+ REFERENCES icon_users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Historie / Aktivitaetsprotokoll
+--
+-- Eine Tabelle fuer alles: Systemereignisse (angelegt, geaendert, geloescht)
+-- und von Hand erfasste Notizen. `entity_type`/`entity_id` sagen, an welcher
+-- Karte der Eintrag haengt; `user_id` immer, wer es war.
+--
+-- Absichtlich keine Fremdschluessel auf die Ziel-Tabellen: die Historie soll
+-- einen geloeschten Kontakt ueberleben. Deshalb steht mit `entity_label` auch
+-- der Name zum Zeitpunkt des Eintrags mit drin.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_history (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ entity_type ENUM('contact','company','tender','project','lead','user','system') NOT NULL,
+ entity_id INT UNSIGNED NULL,
+ entity_label VARCHAR(300) NULL,
+ -- created | updated | deleted | note | mention | interview | stage | ...
+ action VARCHAR(40) NOT NULL DEFAULT 'note',
+ -- Anzeigetext, darf Mention-Token @[Name](contact:12) enthalten.
+ text MEDIUMTEXT NULL,
+ -- Feldaenderungen als JSON: { feld: [vorher, nachher], ... }
+ changes MEDIUMTEXT NULL,
+ user_id INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_icon_history_entity (entity_type, entity_id, created_at),
+ KEY idx_icon_history_created (created_at),
+ CONSTRAINT fk_icon_history_user FOREIGN KEY (user_id)
+ REFERENCES icon_users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Erwaehnungen (@-Mentions)
+--
+-- Der Token steht im Text; diese Tabelle ist der Index dazu. Damit beantwortet
+-- eine Abfrage "wo wurde dieses Unternehmen ueberall genannt?", ohne jeden
+-- Freitext der Datenbank durchsuchen zu muessen.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_mentions (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ -- Wo die Erwaehnung steht.
+ source_type ENUM('history','contact','company','tender','project','lead') NOT NULL,
+ source_id BIGINT UNSIGNED NOT NULL,
+ -- Wer bzw. was erwaehnt wurde.
+ target_type ENUM('contact','company','user') NOT NULL,
+ target_id INT UNSIGNED NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_icon_mentions (source_type, source_id, target_type, target_id),
+ KEY idx_icon_mentions_target (target_type, target_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Netzwerk: wer kennt wen
+--
+-- Das Paar wird aufsteigend gespeichert (a < b), der Unique-Key verhindert
+-- damit Duplikate in beide Richtungen.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_connections (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ contact_a INT UNSIGNED NOT NULL,
+ contact_b INT UNSIGNED NOT NULL,
+ kind VARCHAR(60) NOT NULL DEFAULT 'Kontakt',
+ strength TINYINT UNSIGNED NOT NULL DEFAULT 1,
+ created_by INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_icon_connections (contact_a, contact_b),
+ CONSTRAINT fk_icon_connections_a FOREIGN KEY (contact_a)
+ REFERENCES icon_contacts (id) ON DELETE CASCADE,
+ CONSTRAINT fk_icon_connections_b FOREIGN KEY (contact_b)
+ REFERENCES icon_contacts (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Ausschreibungen
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_tenders (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(300) NOT NULL,
+ source VARCHAR(190) NULL,
+ link VARCHAR(600) NULL,
+ company_id INT UNSIGNED NULL,
+ status ENUM('Aktiv','Inaktiv','Ausgelaufen','Archiviert') NOT NULL DEFAULT 'Aktiv',
+ stage ENUM('Ausschreibung','Kontakte sichten','Interview','Kunden Interview',
+ 'Vertragsgestaltung','Abschluss','Geschlossen') NOT NULL DEFAULT 'Ausschreibung',
+ online TINYINT(1) NOT NULL DEFAULT 1,
+ details MEDIUMTEXT NULL,
+ notes MEDIUMTEXT NULL,
+ deadline DATE NULL,
+ created_by INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ KEY idx_icon_tenders_stage (stage),
+ KEY idx_icon_tenders_status (status),
+ CONSTRAINT fk_icon_tenders_company FOREIGN KEY (company_id)
+ REFERENCES icon_companies (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS icon_tender_contacts (
+ tender_id INT UNSIGNED NOT NULL,
+ contact_id INT UNSIGNED NOT NULL,
+ role VARCHAR(120) NULL,
+ PRIMARY KEY (tender_id, contact_id),
+ CONSTRAINT fk_icon_tc_tender FOREIGN KEY (tender_id)
+ REFERENCES icon_tenders (id) ON DELETE CASCADE,
+ CONSTRAINT fk_icon_tc_contact FOREIGN KEY (contact_id)
+ REFERENCES icon_contacts (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Laufende Projekte
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_projects (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(300) NOT NULL,
+ company_id INT UNSIGNED NULL,
+ contact_id INT UNSIGNED NULL,
+ started_on DATE NULL,
+ ended_on DATE NULL,
+ status ENUM('Aktiv','Nicht aktiv','Abgeschlossen') NOT NULL DEFAULT 'Aktiv',
+ rate DECIMAL(8,2) NULL,
+ notes MEDIUMTEXT NULL,
+ created_by INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ KEY idx_icon_projects_status (status),
+ CONSTRAINT fk_icon_projects_company FOREIGN KEY (company_id)
+ REFERENCES icon_companies (id) ON DELETE SET NULL,
+ CONSTRAINT fk_icon_projects_contact FOREIGN KEY (contact_id)
+ REFERENCES icon_contacts (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Lead-Pipeline (Kanban)
+--
+-- Schema steht schon; die Oberflaeche dazu kommt in der zweiten Ausbaustufe.
+-- `remind_at` traegt die Erinnerungsfunktion, `reminder_days` das Intervall.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_leads (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ title VARCHAR(300) NOT NULL,
+ company_id INT UNSIGNED NULL,
+ contact_id INT UNSIGNED NULL,
+ stage ENUM('Fuer Spaeter','Neuer Lead','Erstkontakt','1. Gespraech',
+ 'Followup geplant','Followup Gespraech','Closed','Absage')
+ NOT NULL DEFAULT 'Neuer Lead',
+ contact_state ENUM('unbekannt','persoenlich','kein Kontakt') NOT NULL DEFAULT 'unbekannt',
+ archived TINYINT(1) NOT NULL DEFAULT 0,
+ position INT NOT NULL DEFAULT 0,
+ notes MEDIUMTEXT NULL,
+ -- Zeitpunkt der naechsten Erinnerung; NULL = keine.
+ remind_at DATETIME NULL,
+ reminder_days INT UNSIGNED NULL,
+ last_contact_at DATETIME NULL,
+ owner_id INT UNSIGNED NULL,
+ created_by INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ KEY idx_icon_leads_stage (stage, archived, position),
+ KEY idx_icon_leads_remind (remind_at),
+ CONSTRAINT fk_icon_leads_company FOREIGN KEY (company_id)
+ REFERENCES icon_companies (id) ON DELETE SET NULL,
+ CONSTRAINT fk_icon_leads_contact FOREIGN KEY (contact_id)
+ REFERENCES icon_contacts (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Suspects - die Stufe vor dem Lead (29.09.2026)
+--
+-- Personen, die interessant sein koennten, als schlichte Tabelle: Name,
+-- Funktion und Unternehmen sind bewusst Freitext, nicht Verweise auf Kontakte
+-- und Unternehmen - ein Suspect soll in Sekunden erfasst sein. Erst beim
+-- Schritt zum Lead wird gegen die Stammdaten abgeglichen.
+--
+-- `remind_at` ist der "naechste Impuls": dann erinnert das CRM daran, die
+-- Person erneut anzusprechen. `lead_id` zeigt nach der Uebergabe auf den Lead;
+-- der Suspect selbst wandert dann ins Archiv.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_suspects (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(190) NOT NULL,
+ role VARCHAR(190) NULL,
+ company VARCHAR(190) NULL,
+ email VARCHAR(190) NULL,
+ linkedin VARCHAR(500) NULL,
+ website VARCHAR(500) NULL,
+ notes TEXT NULL,
+ last_contact_on DATE NULL,
+ remind_at DATETIME NULL,
+ archived TINYINT(1) NOT NULL DEFAULT 0,
+ lead_id INT UNSIGNED NULL,
+ created_by INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ KEY idx_icon_suspects_archived (archived, id),
+ KEY idx_icon_suspects_remind (remind_at),
+ CONSTRAINT fk_icon_suspects_lead FOREIGN KEY (lead_id)
+ REFERENCES icon_leads (id) ON DELETE SET NULL,
+ CONSTRAINT fk_icon_suspects_creator FOREIGN KEY (created_by)
+ REFERENCES icon_users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS icon_lead_todos (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ lead_id INT UNSIGNED NOT NULL,
+ text VARCHAR(500) NOT NULL,
+ done TINYINT(1) NOT NULL DEFAULT 0,
+ due_on DATE NULL,
+ position INT NOT NULL DEFAULT 0,
+ created_by INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_icon_lead_todos_lead (lead_id, position),
+ CONSTRAINT fk_icon_lead_todos_lead FOREIGN KEY (lead_id)
+ REFERENCES icon_leads (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- CV-Import
+--
+-- Der Ablauf aus der Featureliste als drei Ordner in einer Tabelle:
+-- eingang hochgeladen, noch nicht ausgelesen
+-- screening von der KI ausgelesen, wartet auf Ja oder Nein
+-- auswahl uebernommen - der Kontakt ist angelegt, der CV haengt dran
+-- verworfen abgelehnt; die Datei bleibt, bis sie jemand loescht
+--
+-- `extracted` haelt die Ausgabe des Modells als JSON. Sie bleibt auch nach der
+-- Uebernahme stehen: so laesst sich spaeter nachvollziehen, was die KI gelesen
+-- hat und was ein Mensch daran geaendert hat.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_cv_imports (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ filename VARCHAR(255) NOT NULL,
+ original_name VARCHAR(255) NOT NULL,
+ mime VARCHAR(120) NULL,
+ size_bytes INT UNSIGNED NOT NULL DEFAULT 0,
+ status ENUM('eingang','screening','auswahl','verworfen') NOT NULL DEFAULT 'eingang',
+ extracted MEDIUMTEXT NULL,
+ -- Das im Lebenslauf gefundene Portrait, unveraendert als Data-URL. Es steht
+ -- hier nur zwischen Auslesen und Uebernehmen; beim Anlegen des Kontakts geht
+ -- es verkleinert an icon_contacts.avatar und wird hier geleert.
+ photo MEDIUMTEXT NULL,
+ error VARCHAR(500) NULL,
+ contact_id INT UNSIGNED NULL,
+ uploaded_by INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ processed_at TIMESTAMP NULL DEFAULT NULL,
+ KEY idx_icon_cv_status (status, created_at),
+ CONSTRAINT fk_icon_cv_contact FOREIGN KEY (contact_id)
+ REFERENCES icon_contacts (id) ON DELETE SET NULL,
+ CONSTRAINT fk_icon_cv_user FOREIGN KEY (uploaded_by)
+ REFERENCES icon_users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Stammdaten: gepflegte Auswahllisten
+--
+-- Branche und Kontaktart waren Freitext bzw. ein festes ENUM. Beides wird in
+-- der Praxis erweitert - eine neue Branche, eine neue Art von Kontakt - und
+-- beides soll ohne Codeaenderung gehen. Die Werte stehen deshalb hier und
+-- speisen die Auswahlfelder der Oberflaeche.
+--
+-- `field` ist bewusst kein ENUM: eine weitere Liste soll keine Migration
+-- kosten.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_options (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ field VARCHAR(40) NOT NULL,
+ value VARCHAR(190) NOT NULL,
+ position INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_icon_options (field, value),
+ KEY idx_icon_options_field (field, position)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Einstellungen (Schluessel/Wert) - auch fuer einmalige Migrationsmerker
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_settings (
+ key_name VARCHAR(100) NOT NULL PRIMARY KEY,
+ value MEDIUMTEXT NULL,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ---------------------------------------------------------------------------
+-- Scanner: Stellenanzeigen aus Jobbörsen (DACH, jedes Alter)
+--
+-- Ein Suchagent ist eine Liste von Schlagwörtern plus der Börsen, in denen
+-- gesucht wird, plus Ausschlussbegriffen. Jede Anzeige liegt genau einmal in
+-- icon_scanner_jobs (eindeutig je Börse und deren Kennung); welcher Agent sie
+-- mit welcher Güte gefunden hat, steht in icon_scanner_matches.
+--
+-- Bewertet wird nicht per KI, sondern per Zählung: je mehr Schlagwörter eines
+-- Agenten in Titel und Text vorkommen, desto höher der Treffer.
+-- ---------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS icon_scanner_agents (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(190) NOT NULL,
+ -- JSON-Arrays: ["SAP FI", "Interim CFO"], ["ba", "stepstone"], ["Praktikum"]
+ keywords TEXT NOT NULL,
+ sources TEXT NOT NULL,
+ excludes TEXT NULL,
+ -- Schlagwoerter mit Alarm: werden wie `keywords` gesucht und gezaehlt,
+ -- ein Fund meldet sich zusaetzlich mit Ton und Pop-up.
+ alarm_keywords TEXT NULL,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ created_by INT UNSIGNED NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ CONSTRAINT fk_icon_scanner_agents_creator FOREIGN KEY (created_by)
+ REFERENCES icon_users (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS icon_scanner_jobs (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ source VARCHAR(30) NOT NULL,
+ external_id VARCHAR(190) NOT NULL,
+ title VARCHAR(500) NOT NULL,
+ company VARCHAR(300) NULL,
+ -- Treffer im CRM: Unternehmen mit gleichem (normalisiertem) Namen.
+ company_id INT UNSIGNED NULL,
+ location VARCHAR(300) NULL,
+ country CHAR(2) NULL,
+ employment_type VARCHAR(60) NULL,
+ url VARCHAR(1000) NOT NULL,
+ description MEDIUMTEXT NULL,
+ published_at DATETIME NULL,
+ first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_icon_scanner_jobs (source, external_id),
+ KEY idx_icon_scanner_jobs_published (published_at),
+ KEY idx_icon_scanner_jobs_company (company_id),
+ CONSTRAINT fk_icon_scanner_jobs_company FOREIGN KEY (company_id)
+ REFERENCES icon_companies (id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS icon_scanner_matches (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ agent_id INT UNSIGNED NOT NULL,
+ job_id INT UNSIGNED NOT NULL,
+ score TINYINT UNSIGNED NOT NULL DEFAULT 0,
+ -- JSON-Array der gefundenen Schlagwörter
+ hits TEXT NULL,
+ status ENUM('neu','gesehen','in_bearbeitung','verworfen') NOT NULL DEFAULT 'neu',
+ -- Alarm-Schlagwort getroffen; alarm_ack = jemand hat das Pop-up quittiert.
+ alarm TINYINT(1) NOT NULL DEFAULT 0,
+ alarm_ack TINYINT(1) NOT NULL DEFAULT 0,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ UNIQUE KEY uq_icon_scanner_matches (agent_id, job_id),
+ KEY idx_icon_scanner_matches_created (created_at),
+ CONSTRAINT fk_icon_scanner_matches_agent FOREIGN KEY (agent_id)
+ REFERENCES icon_scanner_agents (id) ON DELETE CASCADE,
+ CONSTRAINT fk_icon_scanner_matches_job FOREIGN KEY (job_id)
+ REFERENCES icon_scanner_jobs (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Protokoll der Läufe: wann, wodurch ausgelöst, was je Börse herauskam.
+CREATE TABLE IF NOT EXISTS icon_scanner_runs (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ trigger_kind ENUM('auto','manual') NOT NULL,
+ started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ finished_at TIMESTAMP NULL,
+ jobs_found INT UNSIGNED NOT NULL DEFAULT 0,
+ matches_new INT UNSIGNED NOT NULL DEFAULT 0,
+ -- JSON: { ba: { ok: true, count: 12 }, stepstone: { ok: false, error: "..." } }
+ per_source TEXT NULL,
+ KEY idx_icon_scanner_runs_started (started_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/src/App.tsx b/src/App.tsx
index 77aa73d..9d73b21 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -18,6 +18,7 @@ import { Projects } from './components/projects/Projects';
import { History } from './components/history/History';
import { ScannerDashboard } from './components/scanner/ScannerDashboard';
import { ScannerAgents } from './components/scanner/ScannerAgents';
+import { AlarmWatcher } from './components/scanner/AlarmWatcher';
import { Settings } from './components/settings/Settings';
/*
@@ -197,6 +198,8 @@ export const App: React.FC = () => {
{/* Faellige und liegengebliebene Leads melden sich von selbst -
unabhaengig davon, welche Ansicht gerade offen ist. */}
+ {/* Alarm-Schlagwörter des Scanners: Ton + Pop-up, in jeder Ansicht. */}
+
diff --git a/src/components/scanner/AlarmWatcher.tsx b/src/components/scanner/AlarmWatcher.tsx
new file mode 100644
index 0000000..776eec0
--- /dev/null
+++ b/src/components/scanner/AlarmWatcher.tsx
@@ -0,0 +1,110 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { scannerApi, type ScannerAlarm } from '../../lib/api';
+import { useNavigation } from '../../lib/navigation';
+import { Modal } from '../ui/Modal';
+import { PrimaryButton, SecondaryButton } from '../ui/form';
+import { useToast } from '../Toast';
+import { AlarmBell, SOURCE_LABELS, formatAge, playAlarmSound } from './scannerUi';
+
+/*
+ * Alarm des Scanners: Treffer mit Alarm-Schlagwort melden sich mit Ton und
+ * Pop-up - egal, welche Ansicht offen ist (deshalb hängt der Wächter in App,
+ * nicht im Scanner-Dashboard; beim perinco-Scanner hörte man den Ton nur dort).
+ *
+ * Abgefragt wird jede Minute. Der Scanner läuft stündlich, ein Fund meldet
+ * sich also spätestens eine Minute nach dem Lauf. Quittieren gilt für alle
+ * Benutzer (serverseitig `alarm_ack`).
+ */
+
+const POLL_MS = 60_000;
+
+export const AlarmWatcher: React.FC = () => {
+ const { toast } = useToast();
+ const { setView } = useNavigation();
+ const [alarms, setAlarms] = useState([]);
+ /** Welche Alarme schon geklingelt haben - neue klingeln erneut. */
+ const rung = useRef(new Set());
+
+ const check = useCallback(async () => {
+ try {
+ const { alarms: list } = await scannerApi.alarms();
+ setAlarms(list);
+ if (list.some((alarm) => !rung.current.has(alarm.id))) {
+ list.forEach((alarm) => rung.current.add(alarm.id));
+ playAlarmSound();
+ }
+ } catch {
+ /* kein Netz oder Sitzung abgelaufen - beim nächsten Mal */
+ }
+ }, []);
+
+ useEffect(() => {
+ void check();
+ const timer = setInterval(() => void check(), POLL_MS);
+ return () => clearInterval(timer);
+ }, [check]);
+
+ if (alarms.length === 0) return null;
+
+ const acknowledge = async (goToScanner = false) => {
+ const ids = alarms.map((alarm) => alarm.id);
+ setAlarms([]);
+ try {
+ await scannerApi.ackAlarms(ids);
+ } catch (err) {
+ toast(err instanceof Error ? err.message : 'Quittieren fehlgeschlagen.', 'error');
+ }
+ if (goToScanner) setView('Scanner');
+ };
+
+ return (
+ void acknowledge()}
+ title={
+
+
+ {alarms.length === 1 ? 'Alarm-Treffer im Scanner' : `${alarms.length} Alarm-Treffer im Scanner`}
+
+ }
+ footer={
+ <>
+ void acknowledge()}>
+ Gesehen
+
+ void acknowledge(true)}>
+ Zum Scanner
+
+ >
+ }
+ >
+
- Offene Stellen aus Jobbörsen in DACH, höchstens {overview?.schedule.maxAge ?? 21} Tage alt.
+ Offene Stellen aus Jobbörsen in DACH – jedes Alter, gefiltert wird unten nach „Anzeigenalter“.