diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 65a57f1..ab4c1bd 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -38,6 +38,8 @@ erDiagram longtext avatar "Data-URL" varchar theme "Farbwelt, NULL = perinco" varchar theme_mode "light | dark, NULL = folgt dem System" + tinyint scanner_display_threshold "Anzeige ab %, NULL = globaler Standard" + tinyint scanner_match_threshold "Hinweis ab %, NULL = globaler Standard" varchar password_hash "bcrypt" tinyint is_active } diff --git a/docs/03-scanner.md b/docs/03-scanner.md index b2505fc..d8250be 100644 --- a/docs/03-scanner.md +++ b/docs/03-scanner.md @@ -335,6 +335,14 @@ aktiv, nur langsamer (Chrome bremst Timer versteckter Tabs auf etwa einmal pro Minute) und nur, wenn zuvor irgendwo auf der Seite geklickt wurde (Autoplay-Regel). Browserunabhängig meldet nur die E-Mail. +**Schwellen pro Benutzer:** *Anzeige ab %* und *Hinweis ab %* im Dashboard +gehören dem angemeldeten Benutzer (`crm_users.scanner_display_threshold`, +`scanner_match_threshold`, gelesen und geschrieben über `/my-thresholds`). +`NULL` heißt: der globale Wert aus den Scanner-Einstellungen gilt. Die Schwelle +für die E-Mail bleibt global (`match_threshold`), weil die Pipeline ohne +Benutzer läuft. Die KI-Suche filtert Scanner-Treffer mit der Anzeigeschwelle +des Suchenden. + ## Endpunkte Alle unter `/api/scanner`, alle hinter `requireAuth`: @@ -353,5 +361,6 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`: | PUT/DELETE | `/matches/:id` | Status setzen, löschen | | POST | `/projects/:id/applied-check` | Abgleich „bereits beworben!" von Hand wiederholen | | GET/POST | `/settings` | Geheimnisse maskiert | +| GET/PUT | `/my-thresholds` | eigene Schwellen des angemeldeten Benutzers, `null` = Standard | | POST | `/scan` | Lauf starten, `409` wenn schon einer läuft | | GET | `/status` | läuft ein Lauf, Scheduler an, Datenbankname | diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 9716692..658f8ae 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -857,10 +857,14 @@ Die Filterzeile: | **Zeitraum** | wie alt darf das Projekt sein (Standard: letzte 24 Stunden) | | **Agent** | nur Treffer eines Beraters | | **Anzeige ab %** | schwächere Treffer ausblenden | -| **Hinweis ab %** | ab wann Signalton und E-Mail | +| **Hinweis ab %** | ab wann Signalton und Hinweisbanner | -Die letzten zwei Werte werden **gespeichert** und gelten auch beim nächsten Mal -und in den Einstellungen. Rechts daneben steht `sichtbar / gesamt`. +Die letzten zwei Werte werden **pro Benutzer gespeichert**: Jeder sieht seine +eigene Einstellung, auch am nächsten Tag und an einem anderen Rechner, und +ändert damit nichts für die Kollegen. Wer noch nichts eingestellt hat, bekommt +die Standardwerte aus *Einstellungen → Scanner*. Die E-Mail bei neuen Treffern +richtet sich immer nach dem Standardwert dort, nicht nach dem eigenen Wert. +Rechts daneben steht `sichtbar / gesamt`. Die Liste zeigt standardmäßig die **neuesten Treffer oben** (Spalte *Gefunden am*, absteigend). Ein Klick auf einen Spaltenkopf sortiert um, ein zweiter dreht die diff --git a/docs/10-glossar.md b/docs/10-glossar.md index 87b2984..f4740f6 100644 --- a/docs/10-glossar.md +++ b/docs/10-glossar.md @@ -35,7 +35,7 @@ Quelltext. | **Session** (Portal) | Gespeicherte Cookies einer angemeldeten Börsen-Sitzung, im Playwright-Format `storageState`. | | **Sitzung** (CRM) | Die eigene Anmeldung, als JWT im `localStorage`, 12 Stunden gültig. | | **Suchintervall** | Abstand zwischen zwei automatischen Suchläufen, in Minuten. Einstellung `search_interval`. | -| **Schwellenwert Anzeige** | `display_threshold` - Treffer darunter bleiben im Dashboard ausgeblendet. | +| **Schwellenwert Anzeige** | `display_threshold` - Treffer darunter bleiben im Dashboard ausgeblendet. Globaler Standard; jeder Benutzer kann ihn mit `crm_users.scanner_display_threshold` überschreiben. | | **Schwellenwert Benachrichtigung** | `match_threshold` - ab hier gibt es Signalton und E-Mail. | | **Verbindung** | Kante zwischen zwei Kontakten im Netzwerkdiagramm, mit Art und Stärke. Tabelle `crm_lead_connections`. | | **Volumen** | Tagessatz x Personentage - oder der **Projektpreis**, wenn einer eingetragen ist. Ein mit der Abschlusswahrscheinlichkeit gewichtetes Volumen gibt es nicht (seit 18.08.2026 entfernt); so wird bei perinco nicht gerechnet. | diff --git a/docs/12-suche.md b/docs/12-suche.md index 9eaba03..8fc5b1c 100644 --- a/docs/12-suche.md +++ b/docs/12-suche.md @@ -103,7 +103,8 @@ Treffer dazu und gibt sie als `scanner_matches` mit: | `project_title`, `company`, `portal`, `location`, `rate`, `published_at`, `already_applied`, `url` | `projects` | Mitgegeben wird nur, was auch die Scanner-Ansicht zeigt: Treffer ab der -Anzeigeschwelle (`display_threshold`), die neuesten zuerst, höchstens 400. Die +Anzeigeschwelle des suchenden Benutzers (`crm_users.scanner_display_threshold`, +sonst der globale `display_threshold`), die neuesten zuerst, höchstens 400. Die Projektbeschreibung bleibt draußen – sie ist lang und würde den Kontext sprengen. Ist die Scanner-Datenbank nicht erreichbar, fehlt nur dieser Teil. diff --git a/server/src/routes/scanner.js b/server/src/routes/scanner.js index 59d4263..919bc05 100644 --- a/server/src/routes/scanner.js +++ b/server/src/routes/scanner.js @@ -4,6 +4,7 @@ import fs from 'fs'; import path from 'path'; import { Op } from 'sequelize'; import { Agent, Portal, Project, Match, Setting } from '../scanner/models/index.js'; +import { pool } from '../db.js'; import { parsePdfCv } from '../scanner/services/pdfParser.js'; import { encrypt } from '../scanner/utils/crypto.js'; import { ensureUploadDir, resolveCvPath } from '../scanner/uploadDir.js'; @@ -518,6 +519,51 @@ router.post('/settings', async (req, res) => { } }); +/* + * Persoenliche Schwellen fuers Dashboard. Sie liegen am CRM-Benutzer und nicht + * in den Scanner-Einstellungen, damit jeder seine eigene Ansicht hat. null = + * nichts eigenes gesetzt, dann gilt der globale Standard. + */ +function parseThreshold(value) { + if (value === null || value === undefined || value === '') return null; + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) return undefined; + return Math.max(0, Math.min(100, parsed)); +} + +router.get('/my-thresholds', async (req, res) => { + try { + const [rows] = await pool.query( + 'SELECT scanner_display_threshold, scanner_match_threshold FROM crm_users WHERE id = ?', + [req.user.id] + ); + const row = rows[0] || {}; + res.json({ + display_threshold: row.scanner_display_threshold ?? null, + match_threshold: row.scanner_match_threshold ?? null, + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.put('/my-thresholds', async (req, res) => { + try { + const display = parseThreshold(req.body?.display_threshold); + const match = parseThreshold(req.body?.match_threshold); + if (display === undefined || match === undefined) { + return res.status(400).json({ error: 'Schwellen müssen Zahlen zwischen 0 und 100 sein.' }); + } + await pool.query( + 'UPDATE crm_users SET scanner_display_threshold = ?, scanner_match_threshold = ? WHERE id = ?', + [display, match, req.user.id] + ); + res.json({ display_threshold: display, match_threshold: match }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + // ========================================== // MANUELLER SCAN // ========================================== diff --git a/server/src/routes/search.js b/server/src/routes/search.js index 2e71ab4..141737e 100644 --- a/server/src/routes/search.js +++ b/server/src/routes/search.js @@ -200,7 +200,7 @@ router.post('/ai', async (req, res, next) => { // "dazu steht nichts im Bestand", obwohl die Person in der Liste steht. const icon = await fetchIconContacts(); - const scannerMatches = await loadScannerMatches(); + const scannerMatches = await loadScannerMatches(req.user.id); const truncated = [leads, opportunities, acquisitions, events, scannerMatches].some( (list) => list.length >= AI_LIMIT @@ -248,10 +248,18 @@ router.post('/ai', async (req, res, next) => { * Anzeigeschwelle, ohne Projektbeschreibung - die ist lang und wuerde den * Kontext sprengen. Faellt der Scanner aus, fehlt nur dieser Teil. */ -async function loadScannerMatches() { +async function loadScannerMatches(userId) { try { - const setting = await ScannerSetting.findOne({ where: { keyName: 'display_threshold' } }); - const threshold = Number(setting?.value) || 0; + // Die eigene Schwelle des Suchenden geht vor, sonst der globale Standard. + const [users] = await pool.query( + 'SELECT scanner_display_threshold FROM crm_users WHERE id = ?', + [userId] + ); + let threshold = users[0]?.scanner_display_threshold; + if (threshold === null || threshold === undefined) { + const setting = await ScannerSetting.findOne({ where: { keyName: 'display_threshold' } }); + threshold = Number(setting?.value) || 0; + } const [rows] = await scannerDb.query( `SELECT m.id, m.match_percentage, m.status, m.created_at AS found_at, a.name AS agent, p.title AS project_title, p.company, p.portal, p.location, diff --git a/server/src/schema.sql b/server/src/schema.sql index 1be9aaa..316fecc 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -34,6 +34,12 @@ ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color; ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme VARCHAR(32) NULL AFTER avatar; ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme_mode VARCHAR(16) NULL AFTER theme; +-- Eigene Schwellen im Scanner-Dashboard ("Anzeige ab %", "Hinweis ab %"). +-- NULL = der Standard aus den Scanner-Einstellungen gilt. Die Schwelle fuer +-- Mail-Benachrichtigungen bleibt global, sie laeuft serverseitig ohne Benutzer. +ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS scanner_display_threshold TINYINT UNSIGNED NULL AFTER theme_mode; +ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS scanner_match_threshold TINYINT UNSIGNED NULL AFTER scanner_display_threshold; + -- Objekt-ID des Microsoft-Kontos (Entra), gesetzt bei der ersten Anmeldung per -- SSO. Sie ist die stabile Zuordnung: die E-Mail-Adresse eines Menschen kann -- sich aendern, die Objekt-ID nicht. NULL = noch nie per Microsoft angemeldet; diff --git a/src/components/scanner/ScannerDashboard.tsx b/src/components/scanner/ScannerDashboard.tsx index 2d2172e..4701e33 100644 --- a/src/components/scanner/ScannerDashboard.tsx +++ b/src/components/scanner/ScannerDashboard.tsx @@ -21,6 +21,7 @@ import { type ScannerAgent, type ScannerMatch, type ScannerSettings, + type ScannerThresholds, type ScannerStatus, } from '../../lib/scannerApi'; @@ -165,13 +166,14 @@ export const ScannerDashboard: React.FC = () => { async (silent = false) => { if (!silent) setIsLoading(true); try { - const [agentRows, portalRows, matchRows, settings, statusRow] = await Promise.all([ + const [agentRows, portalRows, matchRows, settings, statusRow, mine] = await Promise.all([ scannerApi.agents(), scannerApi.portals(), scannerApi.matches({ limit: 2000 }), // Fehlende Einstellungen sind kein Grund, das Dashboard leer zu lassen. scannerApi.settings().catch((): Partial => ({})), scannerApi.status().catch((): ScannerStatus | null => null), + scannerApi.myThresholds().catch((): ScannerThresholds | null => null), ]); setAgents(agentRows); @@ -181,16 +183,15 @@ export const ScannerDashboard: React.FC = () => { // den Knopf hier genauso sperren wie ein selbst gestarteter. if (statusRow) setIsScanning(statusRow.scanRunning); - const parsedDisplay = parseInt( - settings.display_threshold ?? SCANNER_SETTINGS_DEFAULTS.display_threshold, - 10 - ); + // Eigene Schwellen des Benutzers gehen vor, sonst der globale Standard. + const parsedDisplay = + mine?.display_threshold ?? + parseInt(settings.display_threshold ?? SCANNER_SETTINGS_DEFAULTS.display_threshold, 10); if (!Number.isNaN(parsedDisplay)) setDisplayThreshold(parsedDisplay); - const parsedNotify = parseInt( - settings.match_threshold ?? SCANNER_SETTINGS_DEFAULTS.match_threshold, - 10 - ); + const parsedNotify = + mine?.match_threshold ?? + parseInt(settings.match_threshold ?? SCANNER_SETTINGS_DEFAULTS.match_threshold, 10); if (!Number.isNaN(parsedNotify)) { notifyThreshold.current = parsedNotify; setMatchThreshold(parsedNotify); @@ -282,15 +283,15 @@ export const ScannerDashboard: React.FC = () => { if (saveTimer.current) window.clearTimeout(saveTimer.current); }, []); - /** Beide Schwellenwerte liegen in den Einstellungen - gebuendelt speichern. */ + /** Beide Schwellenwerte gehoeren dem Benutzer - gebuendelt speichern. */ const persistThresholds = (display: number, notify: number) => { if (saveTimer.current) window.clearTimeout(saveTimer.current); setSaveState('pending'); saveTimer.current = window.setTimeout(async () => { try { - await scannerApi.saveSettings({ - display_threshold: String(display), - match_threshold: String(notify), + await scannerApi.saveMyThresholds({ + display_threshold: display, + match_threshold: notify, }); setSaveState('saved'); window.setTimeout(() => setSaveState('idle'), 1500); diff --git a/src/components/settings/ScannerSettings.tsx b/src/components/settings/ScannerSettings.tsx index b1eb9d4..3a51cab 100644 --- a/src/components/settings/ScannerSettings.tsx +++ b/src/components/settings/ScannerSettings.tsx @@ -167,7 +167,7 @@ export const ScannerSettings: React.FC = () => { className={inputClass} />

- Treffer darunter bleiben im Dashboard ausgeblendet. + Standard: Treffer darunter bleiben im Dashboard ausgeblendet. Jeder Benutzer kann im Dashboard einen eigenen Wert setzen.

@@ -181,7 +181,7 @@ export const ScannerSettings: React.FC = () => { className={inputClass} />

- Ab diesem Wert gibt es Signalton und E-Mail. + Ab diesem Wert gibt es eine E-Mail. Für Signalton und Hinweis ist es der Standard, jeder Benutzer kann im Dashboard einen eigenen Wert setzen.

diff --git a/src/lib/scannerApi.ts b/src/lib/scannerApi.ts index 7d96cd7..9865072 100644 --- a/src/lib/scannerApi.ts +++ b/src/lib/scannerApi.ts @@ -104,6 +104,11 @@ export interface ScannerSettings { applied_check_days: string; } +export interface ScannerThresholds { + display_threshold: number | null; + match_threshold: number | null; +} + export const SCANNER_SETTINGS_DEFAULTS: ScannerSettings = { scanning_active: 'true', search_interval: '60', @@ -214,6 +219,14 @@ export const scannerApi = { body: JSON.stringify(input), }), + // Eigene Schwellen des angemeldeten Benutzers; null = globaler Standard. + myThresholds: () => apiRequest('/scanner/my-thresholds'), + saveMyThresholds: (input: ScannerThresholds) => + apiRequest('/scanner/my-thresholds', { + method: 'PUT', + body: JSON.stringify(input), + }), + // ---------- Scan ---------- scan: () => apiRequest<{ message: string }>('/scanner/scan', { method: 'POST' }), status: () => apiRequest('/scanner/status'),