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 + + + } + > +
    + {alarms.map((alarm) => ( +
  • + + {alarm.title} ↗ + +

    + {[alarm.company, alarm.location, SOURCE_LABELS[alarm.source]].filter(Boolean).join(' · ')} ·{' '} + {formatAge(alarm.published_at)} +

    +

    + Agent {alarm.agent_name} · gefunden:{' '} + {alarm.hits.map((hit) => ( + + {hit} + + ))} +

    +
  • + ))} +
+
+ ); +}; diff --git a/src/components/scanner/ScannerAgents.tsx b/src/components/scanner/ScannerAgents.tsx index b4f9628..657274c 100644 --- a/src/components/scanner/ScannerAgents.tsx +++ b/src/components/scanner/ScannerAgents.tsx @@ -11,7 +11,7 @@ import { useNavigation } from '../../lib/navigation'; import { useToast } from '../Toast'; import { ConfirmDialog, Modal } from '../ui/Modal'; import { Field, PrimaryButton, SecondaryButton, inputClass } from '../ui/form'; -import { SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi'; +import { AlarmBell, SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi'; /* * Scanner -> Suchagenten. @@ -26,6 +26,7 @@ const EMPTY: ScannerAgentInput = { keywords: [], sources: ['ba', 'stepstone', 'karriere'], excludes: [], + alarm_keywords: [], is_active: true, }; @@ -145,6 +146,7 @@ export const ScannerAgents: React.FC = () => { keywords: agent.keywords, sources: agent.sources, excludes: agent.excludes, + alarm_keywords: agent.alarm_keywords, is_active: agent.is_active, }, }) @@ -169,6 +171,19 @@ export const ScannerAgents: React.FC = () => { ))} + {agent.alarm_keywords.length > 0 && ( + + {agent.alarm_keywords.map((term) => ( + + + {term} + + ))} + + )} {agent.sources.map((id) => ( { void save()} > Speichern @@ -236,6 +251,18 @@ export const ScannerAgents: React.FC = () => { /> + + patch({ alarm_keywords })} + placeholder="z. B. Interim CFO" + tone="alarm" + /> + + { const [knownOnly, setKnownOnly] = useState(false); const [showDiscarded, setShowDiscarded] = useState(false); const [search, setSearch] = useState(''); - const [sort, setSort] = useState<'known' | 'score' | 'published'>('known'); + const [sort, setSort] = useState<'known' | 'score' | 'published' | 'oldest'>('known'); + /** Alter der Anzeige: alle | bis 21 Tage | älter als 21 Tage */ + const [age, setAge] = useState<'' | 'recent' | 'old'>(''); + const [alarmOnly, setAlarmOnly] = useState(false); const wasRunning = useRef(false); @@ -77,6 +81,8 @@ export const ScannerDashboard: React.FC = () => { known: knownOnly ? 1 : undefined, status: showDiscarded ? 'alle' : undefined, q: search.trim() || undefined, + age: age || undefined, + alarm: alarmOnly ? 1 : undefined, sort, }); setMatches(data.matches); @@ -84,7 +90,7 @@ export const ScannerDashboard: React.FC = () => { } catch (err) { toast(err instanceof Error ? err.message : 'Treffer nicht ladbar.', 'error'); } - }, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, sort, toast]); + }, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, age, alarmOnly, sort, toast]); const loadOverview = useCallback(async () => { try { @@ -168,7 +174,7 @@ export const ScannerDashboard: React.FC = () => {

- 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“.

{

Stellen-Treffer

- + + + + { + @@ -296,6 +310,10 @@ export const ScannerDashboard: React.FC = () => { setKnownOnly(event.target.checked)} /> nur bekannte Firmen +