feat(scanner): Schwellen im Dashboard pro Benutzer

"Anzeige ab %" und "Hinweis ab %" liegen jetzt am CRM-Benutzer
(crm_users.scanner_display_threshold/scanner_match_threshold) statt global
in den Scanner-Einstellungen. NULL = globaler Standard. Die Mail-Schwelle
bleibt global, die KI-Suche nimmt die Anzeigeschwelle des Suchenden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-10-27 08:50:40 +01:00
co-authored by Claude Opus 5.5
parent e1b69fffbd
commit 69001e9687
11 changed files with 114 additions and 24 deletions
+2
View File
@@ -38,6 +38,8 @@ erDiagram
longtext avatar "Data-URL" longtext avatar "Data-URL"
varchar theme "Farbwelt, NULL = perinco" varchar theme "Farbwelt, NULL = perinco"
varchar theme_mode "light | dark, NULL = folgt dem System" 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" varchar password_hash "bcrypt"
tinyint is_active tinyint is_active
} }
+9
View File
@@ -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 Minute) und nur, wenn zuvor irgendwo auf der Seite geklickt wurde
(Autoplay-Regel). Browserunabhängig meldet nur die E-Mail. (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 ## Endpunkte
Alle unter `/api/scanner`, alle hinter `requireAuth`: 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 | | PUT/DELETE | `/matches/:id` | Status setzen, löschen |
| POST | `/projects/:id/applied-check` | Abgleich „bereits beworben!" von Hand wiederholen | | POST | `/projects/:id/applied-check` | Abgleich „bereits beworben!" von Hand wiederholen |
| GET/POST | `/settings` | Geheimnisse maskiert | | 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 | | POST | `/scan` | Lauf starten, `409` wenn schon einer läuft |
| GET | `/status` | läuft ein Lauf, Scheduler an, Datenbankname | | GET | `/status` | läuft ein Lauf, Scheduler an, Datenbankname |
+7 -3
View File
@@ -857,10 +857,14 @@ Die Filterzeile:
| **Zeitraum** | wie alt darf das Projekt sein (Standard: letzte 24 Stunden) | | **Zeitraum** | wie alt darf das Projekt sein (Standard: letzte 24 Stunden) |
| **Agent** | nur Treffer eines Beraters | | **Agent** | nur Treffer eines Beraters |
| **Anzeige ab %** | schwächere Treffer ausblenden | | **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 Die letzten zwei Werte werden **pro Benutzer gespeichert**: Jeder sieht seine
und in den Einstellungen. Rechts daneben steht `sichtbar / gesamt`. 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*, Die Liste zeigt standardmäßig die **neuesten Treffer oben** (Spalte *Gefunden am*,
absteigend). Ein Klick auf einen Spaltenkopf sortiert um, ein zweiter dreht die absteigend). Ein Klick auf einen Spaltenkopf sortiert um, ein zweiter dreht die
+1 -1
View File
@@ -35,7 +35,7 @@ Quelltext.
| **Session** (Portal) | Gespeicherte Cookies einer angemeldeten Börsen-Sitzung, im Playwright-Format `storageState`. | | **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. | | **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`. | | **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. | | **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`. | | **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. | | **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. |
+2 -1
View File
@@ -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` | | `project_title`, `company`, `portal`, `location`, `rate`, `published_at`, `already_applied`, `url` | `projects` |
Mitgegeben wird nur, was auch die Scanner-Ansicht zeigt: Treffer ab der 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 Projektbeschreibung bleibt draußen – sie ist lang und würde den Kontext
sprengen. Ist die Scanner-Datenbank nicht erreichbar, fehlt nur dieser Teil. sprengen. Ist die Scanner-Datenbank nicht erreichbar, fehlt nur dieser Teil.
+46
View File
@@ -4,6 +4,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { Op } from 'sequelize'; import { Op } from 'sequelize';
import { Agent, Portal, Project, Match, Setting } from '../scanner/models/index.js'; import { Agent, Portal, Project, Match, Setting } from '../scanner/models/index.js';
import { pool } from '../db.js';
import { parsePdfCv } from '../scanner/services/pdfParser.js'; import { parsePdfCv } from '../scanner/services/pdfParser.js';
import { encrypt } from '../scanner/utils/crypto.js'; import { encrypt } from '../scanner/utils/crypto.js';
import { ensureUploadDir, resolveCvPath } from '../scanner/uploadDir.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 // MANUELLER SCAN
// ========================================== // ==========================================
+11 -3
View File
@@ -200,7 +200,7 @@ router.post('/ai', async (req, res, next) => {
// "dazu steht nichts im Bestand", obwohl die Person in der Liste steht. // "dazu steht nichts im Bestand", obwohl die Person in der Liste steht.
const icon = await fetchIconContacts(); const icon = await fetchIconContacts();
const scannerMatches = await loadScannerMatches(); const scannerMatches = await loadScannerMatches(req.user.id);
const truncated = [leads, opportunities, acquisitions, events, scannerMatches].some( const truncated = [leads, opportunities, acquisitions, events, scannerMatches].some(
(list) => list.length >= AI_LIMIT (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 * Anzeigeschwelle, ohne Projektbeschreibung - die ist lang und wuerde den
* Kontext sprengen. Faellt der Scanner aus, fehlt nur dieser Teil. * Kontext sprengen. Faellt der Scanner aus, fehlt nur dieser Teil.
*/ */
async function loadScannerMatches() { async function loadScannerMatches(userId) {
try { try {
// 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' } }); const setting = await ScannerSetting.findOne({ where: { keyName: 'display_threshold' } });
const threshold = Number(setting?.value) || 0; threshold = Number(setting?.value) || 0;
}
const [rows] = await scannerDb.query( const [rows] = await scannerDb.query(
`SELECT m.id, m.match_percentage, m.status, m.created_at AS found_at, `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, a.name AS agent, p.title AS project_title, p.company, p.portal, p.location,
+6
View File
@@ -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 VARCHAR(32) NULL AFTER avatar;
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme_mode VARCHAR(16) NULL AFTER theme; 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 -- 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 -- 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; -- sich aendern, die Objekt-ID nicht. NULL = noch nie per Microsoft angemeldet;
+14 -13
View File
@@ -21,6 +21,7 @@ import {
type ScannerAgent, type ScannerAgent,
type ScannerMatch, type ScannerMatch,
type ScannerSettings, type ScannerSettings,
type ScannerThresholds,
type ScannerStatus, type ScannerStatus,
} from '../../lib/scannerApi'; } from '../../lib/scannerApi';
@@ -165,13 +166,14 @@ export const ScannerDashboard: React.FC = () => {
async (silent = false) => { async (silent = false) => {
if (!silent) setIsLoading(true); if (!silent) setIsLoading(true);
try { try {
const [agentRows, portalRows, matchRows, settings, statusRow] = await Promise.all([ const [agentRows, portalRows, matchRows, settings, statusRow, mine] = await Promise.all([
scannerApi.agents(), scannerApi.agents(),
scannerApi.portals(), scannerApi.portals(),
scannerApi.matches({ limit: 2000 }), scannerApi.matches({ limit: 2000 }),
// Fehlende Einstellungen sind kein Grund, das Dashboard leer zu lassen. // Fehlende Einstellungen sind kein Grund, das Dashboard leer zu lassen.
scannerApi.settings().catch((): Partial<ScannerSettings> => ({})), scannerApi.settings().catch((): Partial<ScannerSettings> => ({})),
scannerApi.status().catch((): ScannerStatus | null => null), scannerApi.status().catch((): ScannerStatus | null => null),
scannerApi.myThresholds().catch((): ScannerThresholds | null => null),
]); ]);
setAgents(agentRows); setAgents(agentRows);
@@ -181,16 +183,15 @@ export const ScannerDashboard: React.FC = () => {
// den Knopf hier genauso sperren wie ein selbst gestarteter. // den Knopf hier genauso sperren wie ein selbst gestarteter.
if (statusRow) setIsScanning(statusRow.scanRunning); if (statusRow) setIsScanning(statusRow.scanRunning);
const parsedDisplay = parseInt( // Eigene Schwellen des Benutzers gehen vor, sonst der globale Standard.
settings.display_threshold ?? SCANNER_SETTINGS_DEFAULTS.display_threshold, const parsedDisplay =
10 mine?.display_threshold ??
); parseInt(settings.display_threshold ?? SCANNER_SETTINGS_DEFAULTS.display_threshold, 10);
if (!Number.isNaN(parsedDisplay)) setDisplayThreshold(parsedDisplay); if (!Number.isNaN(parsedDisplay)) setDisplayThreshold(parsedDisplay);
const parsedNotify = parseInt( const parsedNotify =
settings.match_threshold ?? SCANNER_SETTINGS_DEFAULTS.match_threshold, mine?.match_threshold ??
10 parseInt(settings.match_threshold ?? SCANNER_SETTINGS_DEFAULTS.match_threshold, 10);
);
if (!Number.isNaN(parsedNotify)) { if (!Number.isNaN(parsedNotify)) {
notifyThreshold.current = parsedNotify; notifyThreshold.current = parsedNotify;
setMatchThreshold(parsedNotify); setMatchThreshold(parsedNotify);
@@ -282,15 +283,15 @@ export const ScannerDashboard: React.FC = () => {
if (saveTimer.current) window.clearTimeout(saveTimer.current); 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) => { const persistThresholds = (display: number, notify: number) => {
if (saveTimer.current) window.clearTimeout(saveTimer.current); if (saveTimer.current) window.clearTimeout(saveTimer.current);
setSaveState('pending'); setSaveState('pending');
saveTimer.current = window.setTimeout(async () => { saveTimer.current = window.setTimeout(async () => {
try { try {
await scannerApi.saveSettings({ await scannerApi.saveMyThresholds({
display_threshold: String(display), display_threshold: display,
match_threshold: String(notify), match_threshold: notify,
}); });
setSaveState('saved'); setSaveState('saved');
window.setTimeout(() => setSaveState('idle'), 1500); window.setTimeout(() => setSaveState('idle'), 1500);
+2 -2
View File
@@ -167,7 +167,7 @@ export const ScannerSettings: React.FC = () => {
className={inputClass} className={inputClass}
/> />
<p className="mt-1 text-xs text-ink-400"> <p className="mt-1 text-xs text-ink-400">
Treffer darunter bleiben im Dashboard ausgeblendet. Standard: Treffer darunter bleiben im Dashboard ausgeblendet. Jeder Benutzer kann im Dashboard einen eigenen Wert setzen.
</p> </p>
</div> </div>
<div> <div>
@@ -181,7 +181,7 @@ export const ScannerSettings: React.FC = () => {
className={inputClass} className={inputClass}
/> />
<p className="mt-1 text-xs text-ink-400"> <p className="mt-1 text-xs text-ink-400">
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.
</p> </p>
</div> </div>
</div> </div>
+13
View File
@@ -104,6 +104,11 @@ export interface ScannerSettings {
applied_check_days: string; applied_check_days: string;
} }
export interface ScannerThresholds {
display_threshold: number | null;
match_threshold: number | null;
}
export const SCANNER_SETTINGS_DEFAULTS: ScannerSettings = { export const SCANNER_SETTINGS_DEFAULTS: ScannerSettings = {
scanning_active: 'true', scanning_active: 'true',
search_interval: '60', search_interval: '60',
@@ -214,6 +219,14 @@ export const scannerApi = {
body: JSON.stringify(input), body: JSON.stringify(input),
}), }),
// Eigene Schwellen des angemeldeten Benutzers; null = globaler Standard.
myThresholds: () => apiRequest<ScannerThresholds>('/scanner/my-thresholds'),
saveMyThresholds: (input: ScannerThresholds) =>
apiRequest<ScannerThresholds>('/scanner/my-thresholds', {
method: 'PUT',
body: JSON.stringify(input),
}),
// ---------- Scan ---------- // ---------- Scan ----------
scan: () => apiRequest<{ message: string }>('/scanner/scan', { method: 'POST' }), scan: () => apiRequest<{ message: string }>('/scanner/scan', { method: 'POST' }),
status: () => apiRequest<ScannerStatus>('/scanner/status'), status: () => apiRequest<ScannerStatus>('/scanner/status'),