docs: echte Umlaute in der Dokumentation

893 Stellen in Fliesstext, Tabellen und Mermaid-Beschriftungen. Dazu die
sichtbaren ss-Fälle (ausschließlich, groß, außen, schließt, heißt,
draußen, weiße, anstoßen, Straße).

Nicht angetastet, damit die Doku weiter beschreibt, was wirklich in der
Datenbank steht:

- Codeblöcke und Code-Spans - dort stehen Bezeichner, SQL, Enum-Werte
  und Dateinamen: `mail_pool`, `'uebernommen'`, `extract.js`
- in Mermaid nur der Text in Anführungszeichen; Knotennamen, Pfeile und
  Werte wie source = 'Mail' bleiben, wie sie sind
- `empfaenger` im ER-Diagramm von crm_mail_contact_candidates: die
  Aufzählung dort nennt die Datenbankwerte, nicht die Anzeige

Die Menü-Skizze in der Benutzeranleitung zeigt jetzt die aufgeklappten
Kontakte mit "Aus Mails" samt Zähler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-11 09:45:58 +02:00
co-authored by Claude Opus 5
parent 8b7ce448a6
commit 7973dc07b1
13 changed files with 688 additions and 684 deletions
+145 -145
View File
@@ -1,14 +1,14 @@
# perinco CRM # perinco CRM
CRM im perinco Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB. CRM im perinco Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB.
Enthaelt den portierten *Projekt Scanner* und die lesende Anbindung des Enthält den portierten *Projekt Scanner* und die lesende Anbindung des
Vertriebspostfachs `crm@perinco.de` ueber Microsoft Graph. Vertriebspostfachs `crm@perinco.de` über Microsoft Graph.
> **Dokumentation mit Funktionszeichnungen:** [`docs/`](docs/README.md) > **Dokumentation mit Funktionszeichnungen:** [`docs/`](docs/README.md)
> — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb, > — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb,
> [Postfachanbindung](docs/11-postfach.md). > [Postfachanbindung](docs/11-postfach.md).
> Zum Einstieg: [Anleitung fuer Benutzer](docs/07-anleitung-benutzer.md) · > Zum Einstieg: [Anleitung für Benutzer](docs/07-anleitung-benutzer.md) ·
> [Anleitung fuer Entwickler](docs/08-anleitung-entwickler.md) · > [Anleitung für Entwickler](docs/08-anleitung-entwickler.md) ·
> [Quellcode-Referenz](docs/09-quellcode.md) > [Quellcode-Referenz](docs/09-quellcode.md)
## Corporate Identity ## Corporate Identity
@@ -17,12 +17,12 @@ Direkt aus dem perinco Logo gesampelt:
| Rolle | Wert | | Rolle | Wert |
| --- | --- | | --- | --- |
| Primaerfarbe (Gruen) | `#6BA04A`, Akzent `#68A33F` | | Primärfarbe (Grün) | `#6BA04A`, Akzent `#68A33F` |
| Sekundaerfarbe (Grau) | `#6E6E6E` | | Sekundärfarbe (Grau) | `#6E6E6E` |
| Schrift | Avenir LT (wie perinco.de); Fallback Nunito Sans | | Schrift | Avenir LT (wie perinco.de); Fallback Nunito Sans |
Die Farbskalen liegen als Tailwind-Theme in `src/index.css` (`brand-*`, `ink-*`) Die Farbskalen liegen als Tailwind-Theme in `src/index.css` (`brand-*`, `ink-*`)
sowie die Flaechen `canvas` (Seite) und `surface` (Karten, Leisten, Dialoge). sowie die Flächen `canvas` (Seite) und `surface` (Karten, Leisten, Dialoge).
## Dark Mode ## Dark Mode
@@ -32,21 +32,21 @@ Doppelklick auf den Umschalter stellt das wieder her.
Umgesetzt ist der Dark Mode **ohne `dark:`-Varianten im Markup**: die Klasse Umgesetzt ist der Dark Mode **ohne `dark:`-Varianten im Markup**: die Klasse
`dark` auf `<html>` tauscht in `src/index.css` nur die Werte der Variablen `dark` auf `<html>` tauscht in `src/index.css` nur die Werte der Variablen
`canvas`, `surface`, `ink-*`, `brand-*` und der getoenten Statusfarben. Die `canvas`, `surface`, `ink-*`, `brand-*` und der getönten Statusfarben. Die
Komponenten benutzen durchgaengig diese Skalen und folgen damit automatisch. Komponenten benutzen durchgängig diese Skalen und folgen damit automatisch.
Drei Dinge bleiben in beiden Themes gleich und sind deshalb bewusst so gewaehlt: Drei Dinge bleiben in beiden Themes gleich und sind deshalb bewusst so gewählt:
| Was | Warum | | Was | Warum |
| --- | --- | | --- | --- |
| `ink-950` | liegt nur unter Dialog-Overlays, muss dunkel bleiben | | `ink-950` | liegt nur unter Dialog-Overlays, muss dunkel bleiben |
| `text-white` | steht immer auf farbigen Flaechen (Buttons, Termin-Chips) | | `text-white` | steht immer auf farbigen Flächen (Buttons, Termin-Chips) |
| `brand-500` | das CI-Gruen der Buttons; nur die Tints darum kippen | | `brand-500` | das CI-Grün der Buttons; nur die Tints darum kippen |
Das Netzwerkdiagramm kann im SVG keine Tailwind-Klassen stapeln und benutzt Das Netzwerkdiagramm kann im SVG keine Tailwind-Klassen stapeln und benutzt
deshalb `var(--color-*)` direkt. Das Logo ist fuer helle Hintergruende gemacht - deshalb `var(--color-*)` direkt. Das Logo ist für helle Hintergründe gemacht -
die Utility `.logo-adaptive` dreht im Dark Mode die Helligkeit und holt per die Utility `.logo-adaptive` dreht im Dark Mode die Helligkeit und holt per
`hue-rotate` das Gruen zurueck. `hue-rotate` das Grün zurück.
Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
blitzt beim Laden die helle Variante auf. blitzt beim Laden die helle Variante auf.
@@ -62,11 +62,11 @@ Browser ──► Apache (:443/:80, pcrm.perinco.de)
└──► Microsoft Graph (crm@perinco.de, nur lesend) └──► Microsoft Graph (crm@perinco.de, nur lesend)
``` ```
Der Scanner haengt unter `/api/scanner`, die Postfachanbindung unter `/api/mail` - Der Scanner hängt unter `/api/scanner`, die Postfachanbindung unter `/api/mail` -
beide brauchen damit **keine** eigene Apache-Regel. beide brauchen damit **keine** eigene Apache-Regel.
Der Browser spricht **nie** direkt mit MariaDB - deshalb das Express-Backend. Der Browser spricht **nie** direkt mit MariaDB - deshalb das Express-Backend.
Das Backend bindet ausschliesslich auf `127.0.0.1` und ist von aussen nur ueber Das Backend bindet ausschließlich auf `127.0.0.1` und ist von außen nur über
den Apache-Proxy erreichbar. den Apache-Proxy erreichbar.
### Datenmodell ### Datenmodell
@@ -74,25 +74,25 @@ den Apache-Proxy erreichbar.
| Tabelle | Inhalt | | Tabelle | Inhalt |
| --- | --- | | --- | --- |
| `crm_users` | Benutzer, Rolle `admin` oder `user`, bcrypt-Hash, `color` (Kalenderfarbe), `avatar` (Foto) | | `crm_users` | Benutzer, Rolle `admin` oder `user`, bcrypt-Hash, `color` (Kalenderfarbe), `avatar` (Foto) |
| `crm_leads` | Kontakte inkl. `created_by` (Ersteller) und `owner_id` (zustaendig); `mail_pool = 1` ist der Vorbereich „Aus Mails" aus dem Postfachabgleich | | `crm_leads` | Kontakte inkl. `created_by` (Ersteller) und `owner_id` (zuständig); `mail_pool = 1` ist der Vorbereich „Aus Mails" aus dem Postfachabgleich |
| `crm_opportunities` | Vertriebschancen: Phase, Tagessatz, Laufzeit, letzte Interaktion, Wahrscheinlichkeit | | `crm_opportunities` | Vertriebschancen: Phase, Tagessatz, Laufzeit, letzte Interaktion, Wahrscheinlichkeit |
| `crm_opportunity_contacts` | Ansprechpartner je Opportunity (n:m auf `crm_leads`) mit Rolle | | `crm_opportunity_contacts` | Ansprechpartner je Opportunity (n:m auf `crm_leads`) mit Rolle |
| `crm_lead_connections` | Wer steht mit wem in Kontakt; Paar aufsteigend gespeichert, Unique-Key verhindert Duplikate | | `crm_lead_connections` | Wer steht mit wem in Kontakt; Paar aufsteigend gespeichert, Unique-Key verhindert Duplikate |
| `crm_events` | Termine des gemeinsamen Teamkalenders; `description` kann Mention-Tokens enthalten | | `crm_events` | Termine des gemeinsamen Teamkalenders; `description` kann Mention-Tokens enthalten |
| `crm_event_participants` | Teilnehmer je Termin (n:m), Primaerschluessel verhindert Doppeleintraege | | `crm_event_participants` | Teilnehmer je Termin (n:m), Primärschlüssel verhindert Doppeleinträge |
### Rollen ### Rollen
* `admin` - sieht den Menuepunkt Einstellungen inkl. Nutzerverwaltung * `admin` - sieht den Menüpunkt Einstellungen inkl. Nutzerverwaltung
* `user` - sieht Einstellungen nicht; die API weist Zugriffe zusaetzlich mit 403 ab * `user` - sieht Einstellungen nicht; die API weist Zugriffe zusätzlich mit 403 ab
Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator
degradiert, deaktiviert oder geloescht wird. degradiert, deaktiviert oder gelöscht wird.
Ausnahme von der Admin-Regel ist `GET /api/users/directory`: die Benutzerliste Ausnahme von der Admin-Regel ist `GET /api/users/directory`: die Benutzerliste
brauchen alle Rollen fuer Netzwerkdiagramm, Zustaendigkeiten, @-Mentions, brauchen alle Rollen für Netzwerkdiagramm, Zuständigkeiten, @-Mentions,
Teilnehmerauswahl und Farben. Passwort-Hashes liefert der Endpunkt nie. Teilnehmerauswahl und Farben. Passwort-Hashes liefert der Endpunkt nie.
Termine darf jeder anlegen; aendern und loeschen nur der Ersteller oder ein Termine darf jeder anlegen; ändern und löschen nur der Ersteller oder ein
Administrator. Administrator.
## Struktur ## Struktur
@@ -173,40 +173,40 @@ deploy/
## Opportunities ## Opportunities
Erster Reiter im Menue und Startansicht. Drei Ansichten wie bei den Kontakten, Erster Reiter im Menü und Startansicht. Drei Ansichten wie bei den Kontakten,
umgeschaltet oben rechts: umgeschaltet oben rechts:
| Ansicht | Inhalt | | Ansicht | Inhalt |
| --- | --- | | --- | --- |
| **Kachel** | Karten mit Volumen, Tagessatz, Laufzeit, Zeitraum, letzter Interaktion und naechstem Schritt | | **Kachel** | Karten mit Volumen, Tagessatz, Laufzeit, Zeitraum, letzter Interaktion und nächstem Schritt |
| **Liste** | sortierbare Tabelle, Spaltenkopf klicken kehrt die Richtung um | | **Liste** | sortierbare Tabelle, Spaltenkopf klicken kehrt die Richtung um |
| **Pipeline** | Kanban nach Phase, Karten per Drag & Drop verschiebbar | | **Pipeline** | Kanban nach Phase, Karten per Drag & Drop verschiebbar |
Die dritte Ansicht ist bewusst eine Pipeline statt eines Netzwerks: bei einer Die dritte Ansicht ist bewusst eine Pipeline statt eines Netzwerks: bei einer
Vertriebschance interessiert der Fortschritt durch die Phasen. Wer nicht ziehen Vertriebschance interessiert der Fortschritt durch die Phasen. Wer nicht ziehen
mag oder auf dem Tablet arbeitet, aendert die Phase ueber das Auswahlfeld auf der mag oder auf dem Tablet arbeitet, ändert die Phase über das Auswahlfeld auf der
Karte. Karte.
**Felder:** Titel, Firma, Projektbeschreibung (in Kachel und Liste **Felder:** Titel, Firma, Projektbeschreibung (in Kachel und Liste
aufklappbar), Phase, Abschlusswahrscheinlichkeit, Tagessatz, Laufzeit in aufklappbar), Phase, Abschlusswahrscheinlichkeit, Tagessatz, Laufzeit in
Personentagen (halbe Tage moeglich), Projektstart und -ende, letzte Interaktion Personentagen (halbe Tage möglich), Projektstart und -ende, letzte Interaktion
mit Notiz, naechster Schritt, Tags, interne Notizen, Zustaendigkeit sowie mit Notiz, nächster Schritt, Tags, interne Notizen, Zuständigkeit sowie
beliebig viele Ansprechpartner aus den Kontakten - jeder mit eigener Rolle. beliebig viele Ansprechpartner aus den Kontakten - jeder mit eigener Rolle.
**Volumen** rechnet die App aus Tagessatz x Personentagen, **gewichtet** zusaetzlich **Volumen** rechnet die App aus Tagessatz x Personentagen, **gewichtet** zusätzlich
mit der Wahrscheinlichkeit. Beides steht live im Formular, pro Karte, als Summe mit der Wahrscheinlichkeit. Beides steht live im Formular, pro Karte, als Summe
ueber der Liste, je Pipeline-Spalte und in der Kopfzeile. über der Liste, je Pipeline-Spalte und in der Kopfzeile.
**Sortieren** geht doppelt: ueber das Auswahlfeld in der Steuerleiste (wirkt in **Sortieren** geht doppelt: über das Auswahlfeld in der Steuerleiste (wirkt in
allen Ansichten) und in der Liste ueber die Spaltenkoepfe. Geld- und Datumsfelder allen Ansichten) und in der Liste über die Spaltenköpfe. Geld- und Datumsfelder
starten absteigend, Text aufsteigend; leere Werte landen immer hinten. starten absteigend, Text aufsteigend; leere Werte landen immer hinten.
Aendern und loeschen darf der Ersteller, der Zustaendige und jeder Administrator. Ändern und löschen darf der Ersteller, der Zuständige und jeder Administrator.
## Projekt Scanner ## Projekt Scanner
Der eigenstaendige *Projekt Scanner* ist vollstaendig hier eingezogen und soll Der eigenständige *Projekt Scanner* ist vollständig hier eingezogen und soll
die alte App ersetzen. Die laeuft weiter unter `scanner.perinco.de`: die alte App ersetzen. Die läuft weiter unter `scanner.perinco.de`:
``` ```
/var/www/Projekt Scanner/backend Express auf 0.0.0.0:5005 /var/www/Projekt Scanner/backend Express auf 0.0.0.0:5005
@@ -215,24 +215,24 @@ systemd-Unit: project-scanner-backend (nicht "project-scanner")
``` ```
Bis zur Ablosung laufen **beide parallel und beide voll funktional**: eigene Bis zur Ablosung laufen **beide parallel und beide voll funktional**: eigene
Datenbank, eigener Verschluesselungsschluessel, eigenes Upload-Verzeichnis, Datenbank, eigener Verschlüsselungsschlüssel, eigenes Upload-Verzeichnis,
eigener Scheduler. Die alte App bleibt unveraendert - weder ihre Dateien noch eigener Scheduler. Die alte App bleibt unverändert - weder ihre Dateien noch
ihre Datenbank werden angefasst. ihre Datenbank werden angefasst.
Menuepunkt **Scanner** links; ein Klick klappt die Reiter der alten App darunter Menüpunkt **Scanner** links; ein Klick klappt die Reiter der alten App darunter
auf: auf:
| Reiter | Inhalt | | Reiter | Inhalt |
| --- | --- | | --- | --- |
| **Dashboard** | Kennzahlen, Filter (Zeitraum, Agent, Schwellenwerte), sortierbare Match-Tabelle, *Jetzt scannen* | | **Dashboard** | Kennzahlen, Filter (Zeitraum, Agent, Schwellenwerte), sortierbare Match-Tabelle, *Jetzt scannen* |
| **Unpassend** | als „passt nicht" markierte Treffer; zurueckholen oder loeschen | | **Unpassend** | als „passt nicht" markierte Treffer; zurückholen oder löschen |
| **Suchagenten** | Beraterprofile: Name, E-Mail, Keywords, Ausschlussbegriffe, CV-PDF, aktiv/inaktiv | | **Suchagenten** | Beraterprofile: Name, E-Mail, Keywords, Ausschlussbegriffe, CV-PDF, aktiv/inaktiv |
| **Boersen-Konten** | Verbindungsstatus je Portal, Zugangsdaten, Cookie-Import, Verbindungstest | | **Börsen-Konten** | Verbindungsstatus je Portal, Zugangsdaten, Cookie-Import, Verbindungstest |
Die **Einstellungen** des Scanners stehen nicht im Untermenue, sondern als Reiter Die **Einstellungen** des Scanners stehen nicht im Untermenü, sondern als Reiter
*Scanner* unter *Einstellungen* - also nur fuer Rolle `admin`. Die Reiter selbst *Scanner* unter *Einstellungen* - also nur für Rolle `admin`. Die Reiter selbst
sieht jede Rolle. Anzeige- und Benachrichtigungs-Schwellenwert lassen sich an sieht jede Rolle. Anzeige- und Benachrichtigungs-Schwellenwert lassen sich an
beiden Stellen aendern; es ist derselbe Wert in der Datenbank. beiden Stellen ändern; es ist derselbe Wert in der Datenbank.
### Was portiert wurde ### Was portiert wurde
@@ -241,60 +241,60 @@ beiden Stellen aendern; es ist derselbe Wert in der Datenbank.
| Sequelize-Modelle (`agents`, `portals`, `projects`, `matches`, `settings`) | `server/src/scanner/models/` | | Sequelize-Modelle (`agents`, `portals`, `projects`, `matches`, `settings`) | `server/src/scanner/models/` |
| Scraper (Playwright), Matching (OpenAI `gpt-4o-mini`), Pipeline, Scheduler, Cleanup, PDF-Parser | `server/src/scanner/services/` | | Scraper (Playwright), Matching (OpenAI `gpt-4o-mini`), Pipeline, Scheduler, Cleanup, PDF-Parser | `server/src/scanner/services/` |
| REST-API | `server/src/routes/scanner.js`, gemountet als `/api/scanner` | | REST-API | `server/src/routes/scanner.js`, gemountet als `/api/scanner` |
| Oberflaeche | `src/components/scanner/`, in Tailwind und CI-Farben neu gebaut | | Oberfläche | `src/components/scanner/`, in Tailwind und CI-Farben neu gebaut |
| Uebernahme aus der alten DB | `server/src/scanner/importLegacy.js` (`npm run scanner:import`) | | Übernahme aus der alten DB | `server/src/scanner/importLegacy.js` (`npm run scanner:import`) |
Drei bewusste Unterschiede zum Original: Drei bewusste Unterschiede zum Original:
1. **Anmeldung** Die HTTP-Basic-Auth des Scanners entfaellt - alle Endpunkte 1. **Anmeldung** Die HTTP-Basic-Auth des Scanners entfällt - alle Endpunkte
liegen hinter `requireAuth`, also der CRM-Sitzung. liegen hinter `requireAuth`, also der CRM-Sitzung.
2. **CV-PDFs** kommen ueber `GET /api/scanner/agents/:id/cv` statt aus einem 2. **CV-PDFs** kommen über `GET /api/scanner/agents/:id/cv` statt aus einem
statischen Verzeichnis: ein Link im neuen Tab kann kein Bearer-Token statischen Verzeichnis: ein Link im neuen Tab kann kein Bearer-Token
mitschicken, deshalb holt das Frontend die Datei und oeffnet sie als Blob. mitschicken, deshalb holt das Frontend die Datei und öffnet sie als Blob.
3. **Meldungen** laufen als Toast statt `alert()`, Loeschabfragen als Dialog. 3. **Meldungen** laufen als Toast statt `alert()`, Löschabfragen als Dialog.
### Parallelbetrieb - alles doppelt ### Parallelbetrieb - alles doppelt
Beide Scanner laufen **gleichzeitig und funktional**, ohne sich zu beruehren. Beide Scanner laufen **gleichzeitig und funktional**, ohne sich zu berühren.
Dafuer ist hier jede geteilte Ressource verdoppelt: Dafür ist hier jede geteilte Ressource verdoppelt:
| | alte App | diese Instanz | | | alte App | diese Instanz |
| --- | --- | --- | | --- | --- | --- |
| Datenbank | `project_scanner` | `perinco_scanner` (eigene Tabellen, gleiche Namen) | | Datenbank | `project_scanner` | `perinco_scanner` (eigene Tabellen, gleiche Namen) |
| Verschluesselung | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` - eigener Wert | | Verschlüsselung | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` - eigener Wert |
| CV-PDFs | `/var/www/Projekt Scanner/backend/uploads` | `SCANNER_UPLOAD_DIR` = `/var/www/perinco_CRM/server/uploads` | | CV-PDFs | `/var/www/Projekt Scanner/backend/uploads` | `SCANNER_UPLOAD_DIR` = `/var/www/perinco_CRM/server/uploads` |
| Scheduler | eigener Cron im alten Dienst | `SCANNER_SCHEDULER`, Standard `true` | | Scheduler | eigener Cron im alten Dienst | `SCANNER_SCHEDULER`, Standard `true` |
| Aufraeumen | 03:00 | 03:30 | | Aufräumen | 03:00 | 03:30 |
Die Scanner-Datenbank legt das Backend beim Start **selbst an** Die Scanner-Datenbank legt das Backend beim Start **selbst an**
(`CREATE DATABASE IF NOT EXISTS`), die Tabellen kommen aus `sequelize.sync()`, (`CREATE DATABASE IF NOT EXISTS`), die Tabellen kommen aus `sequelize.sync()`,
Portale und Standardeinstellungen werden geseedet. Es genuegt also, das Backend Portale und Standardeinstellungen werden geseedet. Es genügt also, das Backend
zu starten. zu starten.
| Variable | Bedeutung | | Variable | Bedeutung |
| --- | --- | | --- | --- |
| `SCANNER_DB_NAME` | eigene Datenbank, Standard `perinco_scanner` - **nicht** auf `project_scanner` zeigen lassen | | `SCANNER_DB_NAME` | eigene Datenbank, Standard `perinco_scanner` - **nicht** auf `project_scanner` zeigen lassen |
| `SCANNER_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten | | `SCANNER_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten |
| `SCANNER_ENCRYPTION_KEY` | eigener Schluessel fuer die Portal-Passwoerter; ohne ihn wird ein bekannter Ersatzschluessel benutzt und beim Start gewarnt | | `SCANNER_ENCRYPTION_KEY` | eigener Schlüssel für die Portal-Passwörter; ohne ihn wird ein bekannter Ersatzschlüssel benutzt und beim Start gewarnt |
| `SCANNER_UPLOAD_DIR` | eigenes CV-Verzeichnis; leer = `<server>/uploads` | | `SCANNER_UPLOAD_DIR` | eigenes CV-Verzeichnis; leer = `<server>/uploads` |
| `SCANNER_SCHEDULER` | Hintergrund-Scraper; Standard an, nur `false` schaltet ab | | `SCANNER_SCHEDULER` | Hintergrund-Scraper; Standard an, nur `false` schaltet ab |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den **allerersten** Lauf, Standard `30`. Danach traegt `settings.last_scan_at` den Zeitplan ueber Neustarts hinweg | | `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz für den **allerersten** Lauf, Standard `30`. Danach trägt `settings.last_scan_at` den Zeitplan über Neustarts hinweg |
> **Kosten:** Beide Instanzen bewerten dieselben Projekte unabhaengig > **Kosten:** Beide Instanzen bewerten dieselben Projekte unabhängig
> voneinander - die OpenAI-Kosten fallen also doppelt an, solange die alte App > voneinander - die OpenAI-Kosten fallen also doppelt an, solange die alte App
> laeuft. Das ist der Preis des Parallelbetriebs, kein Fehler. Wer nur zuschauen > läuft. Das ist der Preis des Parallelbetriebs, kein Fehler. Wer nur zuschauen
> will, setzt `SCANNER_SCHEDULER=false`; der Knopf *Jetzt scannen* funktioniert > will, setzt `SCANNER_SCHEDULER=false`; der Knopf *Jetzt scannen* funktioniert
> dann weiter. > dann weiter.
Der **Zeitversatz** ist nicht Kosmetik: beide Instanzen benutzen dieselben Der **Zeitversatz** ist nicht Kosmetik: beide Instanzen benutzen dieselben
Portal-Sessions (aus derselben Cookie-Quelle) und wuerden beim ersten Start Portal-Sessions (aus derselben Cookie-Quelle) und würden beim ersten Start
gleichzeitig losziehen - das provoziert Rate-Limits und CAPTCHAs bei gleichzeitig losziehen - das provoziert Rate-Limits und CAPTCHAs bei
Freelancermap und Freelance.de. `SCANNER_SCHEDULER_OFFSET=30` verschiebt den Freelancermap und Freelance.de. `SCANNER_SCHEDULER_OFFSET=30` verschiebt den
ersten Lauf um 30 Minuten, danach laufen beide im eigenen Intervall verschraenkt. ersten Lauf um 30 Minuten, danach laufen beide im eigenen Intervall verschränkt.
Ab dem zweiten Start traegt `settings.last_scan_at` den Zeitplan - die Frist Ab dem zweiten Start trägt `settings.last_scan_at` den Zeitplan - die Frist
greift dann nicht mehr. Sonst begann sie bei jedem Deployment von vorn und ein greift dann nicht mehr. Sonst begann sie bei jedem Deployment von vorn und ein
faelliger Lauf konnte beliebig lange ausfallen (siehe fälliger Lauf konnte beliebig lange ausfallen (siehe
[docs/03-scanner.md](docs/03-scanner.md#wann-laeuft-er)). [docs/03-scanner.md](docs/03-scanner.md#wann-laeuft-er)).
Innerhalb dieser Instanz kann **nur ein Lauf** gleichzeitig arbeiten: Scheduler Innerhalb dieser Instanz kann **nur ein Lauf** gleichzeitig arbeiten: Scheduler
@@ -303,11 +303,11 @@ Aufruf bekommt 409 und den Hinweis *„Ein Suchlauf läuft bereits"*. Das Dashbo
zeigt oben links den Zustand - automatische Suche aktiv, nur manuelle Suche, zeigt oben links den Zustand - automatische Suche aktiv, nur manuelle Suche,
pausiert, Suchlauf läuft - samt Namen der Datenbank. pausiert, Suchlauf läuft - samt Namen der Datenbank.
### Daten aus der alten App uebernehmen ### Daten aus der alten App übernehmen
Damit die neue Instanz nicht bei Null anfaengt, holt ein Skript Agenten, Portale Damit die neue Instanz nicht bei Null anfängt, holt ein Skript Agenten, Portale
und Einstellungen herueber. Es liest die alte Datenbank nur, geschrieben wird und Einstellungen herüber. Es liest die alte Datenbank nur, geschrieben wird
ausschliesslich in `perinco_scanner`: ausschließlich in `perinco_scanner`:
```bash ```bash
cd /var/www/perinco_CRM/server cd /var/www/perinco_CRM/server
@@ -319,42 +319,42 @@ npm run scanner:import -- --force # vorhandene Zieldaten vorher loesche
Quelle kommt aus `LEGACY_*` in `server/.env`: Quelle kommt aus `LEGACY_*` in `server/.env`:
| Variable | Wofuer | | Variable | Wofür |
| --- | --- | | --- | --- |
| `LEGACY_DB_NAME` | Standard `project_scanner` | | `LEGACY_DB_NAME` | Standard `project_scanner` |
| `LEGACY_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten | | `LEGACY_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten |
| `LEGACY_UPLOAD_DIR` | Verzeichnis der alten CV-PDFs (`/var/www/Projekt Scanner/backend/uploads`); die Dateien werden **kopiert**. Wegen des Leerzeichens im Pfad in der `.env` in Anfuehrungszeichen setzen - systemd liest die Datei als `EnvironmentFile` und trennt sonst am Leerzeichen | | `LEGACY_UPLOAD_DIR` | Verzeichnis der alten CV-PDFs (`/var/www/Projekt Scanner/backend/uploads`); die Dateien werden **kopiert**. Wegen des Leerzeichens im Pfad in der `.env` in Anführungszeichen setzen - systemd liest die Datei als `EnvironmentFile` und trennt sonst am Leerzeichen |
| `LEGACY_ENCRYPTION_KEY` | nur damit lassen sich die Portal-Passwoerter uebernehmen: entschluesseln mit dem alten, neu verschluesseln mit dem eigenen Schluessel | | `LEGACY_ENCRYPTION_KEY` | nur damit lassen sich die Portal-Passwörter übernehmen: entschlüsseln mit dem alten, neu verschlüsseln mit dem eigenen Schlüssel |
Uebernommen werden auch die Session-Cookies der Portale (Klartext-JSON in der Übernommen werden auch die Session-Cookies der Portale (Klartext-JSON in der
DB) - der neue Scanner ist damit sofort scharf. DB) - der neue Scanner ist damit sofort scharf.
Agenten, Projekte und Treffer behalten ihre Ids, damit uebernommene Treffer Agenten, Projekte und Treffer behalten ihre Ids, damit übernommene Treffer
weiter auf die richtigen Agenten und Projekte zeigen. Deshalb bricht das Skript weiter auf die richtigen Agenten und Projekte zeigen. Deshalb bricht das Skript
ab, wenn im Ziel schon Agenten stehen - `--force` raeumt vorher auf. ab, wenn im Ziel schon Agenten stehen - `--force` räumt vorher auf.
> **Beim Rollout am 06.08.2026 festgestellt:** Die in der alten Datenbank > **Beim Rollout am 06.08.2026 festgestellt:** Die in der alten Datenbank
> gespeicherten Portal-Passwoerter lassen sich mit dem dortigen > gespeicherten Portal-Passwörter lassen sich mit dem dortigen
> `ENCRYPTION_KEY` **nicht** mehr entschluesseln - der Schluessel wurde nach dem > `ENCRYPTION_KEY` **nicht** mehr entschlüsseln - der Schlüssel wurde nach dem
> Speichern getauscht. Die alte App kann sie damit ebenso wenig lesen; es ging > Speichern getauscht. Die alte App kann sie damit ebenso wenig lesen; es ging
> also nichts verloren. Wer den Notfall-Login des Scrapers will, gibt die > also nichts verloren. Wer den Notfall-Login des Scrapers will, gibt die
> Passwoerter unter *Boersen-Konten* neu ein. Die Sessions (Cookies) sind davon > Passwörter unter *Börsen-Konten* neu ein. Die Sessions (Cookies) sind davon
> unberuehrt und wurden uebernommen. > unberührt und wurden übernommen.
### Playwright ### Playwright
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die laufende `playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die laufende
Scanner-App. Deren Chromium-Build (`chromium-1223`) liegt auf dem Server bereits Scanner-App. Deren Chromium-Build (`chromium-1223`) liegt auf dem Server bereits
in `/root/.cache/ms-playwright`, also faellt kein zweiter Browser-Download an und in `/root/.cache/ms-playwright`, also fällt kein zweiter Browser-Download an und
gescrapt wird mit der Version, die dort produktiv funktioniert. Ein `^` in der gescrapt wird mit der Version, die dort produktiv funktioniert. Ein `^` in der
Version wuerde beides aufgeben, deshalb steht in `server/package.json` ein Version würde beides aufgeben, deshalb steht in `server/package.json` ein
`comments`-Feld mit dieser Begruendung. `comments`-Feld mit dieser Begründung.
## Diktat (Whisper im Browser) ## Diktat (Whisper im Browser)
Ueberall, wo laengerer Text entsteht - Projektbeschreibung, Interaktionsnotiz, Überall, wo längerer Text entsteht - Projektbeschreibung, Interaktionsnotiz,
naechster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung nächster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung
laeuft **vollstaendig im Browser des Benutzers**: Whisper als ONNX-Modell ueber läuft **vollständig im Browser des Benutzers**: Whisper als ONNX-Modell über
`@huggingface/transformers`, gerechnet auf der Grafikkarte (WebGPU) oder `@huggingface/transformers`, gerechnet auf der Grafikkarte (WebGPU) oder
ersatzweise auf der CPU (WASM). ersatzweise auf der CPU (WASM).
@@ -366,8 +366,8 @@ Das Backend ist daran **nicht beteiligt** und sieht keine Audiodaten - es gibt
keinen API-Key, keine Kosten pro Minute und keinen Datenabfluss. Aufnahmen keinen API-Key, keine Kosten pro Minute und keinen Datenabfluss. Aufnahmen
werden nirgends gespeichert. werden nirgends gespeichert.
* Die Erkennung laeuft in einem **Web Worker** (`src/lib/whisper.worker.ts`), * Die Erkennung läuft in einem **Web Worker** (`src/lib/whisper.worker.ts`),
damit die Oberflaeche waehrenddessen nicht einfriert. damit die Oberfläche währenddessen nicht einfriert.
* Die **ONNX-Laufzeit** wird mit ausgeliefert (Vite bundelt die `.wasm`), die * Die **ONNX-Laufzeit** wird mit ausgeliefert (Vite bundelt die `.wasm`), die
**Modelldateien** kommen beim ersten Laden vom Hugging-Face-CDN und bleiben **Modelldateien** kommen beim ersten Laden vom Hugging-Face-CDN und bleiben
danach im Browser-Cache. Wer auch das vermeiden will, kann die Modelle auf den danach im Browser-Cache. Wer auch das vermeiden will, kann die Modelle auf den
@@ -375,42 +375,42 @@ werden nirgends gespeichert.
### Reiter "Whisper" ### Reiter "Whisper"
Unten im Menue, ueber den Einstellungen, fuer **alle Rollen** sichtbar. Es gibt Unten im Menü, über den Einstellungen, für **alle Rollen** sichtbar. Es gibt
dort **nichts zu konfigurieren** - genau ein Knopf: dort **nichts zu konfigurieren** - genau ein Knopf:
1. Reiter oeffnen, dort steht *"Einmalig werden 299 MB Sprachmodell geladen."* 1. Reiter öffnen, dort steht *"Einmalig werden 299 MB Sprachmodell geladen."*
2. **Sprachmodell laden (299 MB)** klicken, Fortschrittsbalken abwarten 2. **Sprachmodell laden (299 MB)** klicken, Fortschrittsbalken abwarten
3. Fertig - ab jetzt sind die Mikrofon-Buttons in den Formularen aktiv, auch 3. Fertig - ab jetzt sind die Mikrofon-Buttons in den Formularen aktiv, auch
nach jeder weiteren Anmeldung nach jeder weiteren Anmeldung
Dazu gibt es eine Probeaufnahme und **Sprachmodell loeschen**, falls man den Dazu gibt es eine Probeaufnahme und **Sprachmodell löschen**, falls man den
Platz zurueckhaben will. Sonst nichts: keine Modellauswahl, keine Genauigkeit, Platz zurückhaben will. Sonst nichts: keine Modellauswahl, keine Genauigkeit,
kein Rechenweg, keine Geraetetabelle. kein Rechenweg, keine Gerätetabelle.
Das ist Absicht. Modell, Genauigkeit, Rechenweg und Sprache stehen als Das ist Absicht. Modell, Genauigkeit, Rechenweg und Sprache stehen als
Konstanten in `src/lib/whisper.tsx` - `small`, `q4`, Grafikkarte wenn vorhanden, Konstanten in `src/lib/whisper.tsx` - `small`, `q4`, Grafikkarte wenn vorhanden,
Deutsch. Jede andere Kombination war in den Messungen entweder schlechter oder Deutsch. Jede andere Kombination war in den Messungen entweder schlechter oder
kaputt (siehe unten); eine Auswahl haette also nur Gelegenheit geboten, etwas kaputt (siehe unten); eine Auswahl hätte also nur Gelegenheit geboten, etwas
falsch zu machen. falsch zu machen.
Beim Anmelden wird das Modell automatisch bereitgestellt, **wenn** es schon in Beim Anmelden wird das Modell automatisch bereitgestellt, **wenn** es schon in
diesem Browser liegt - ein Download von 759 MB startet nie von allein. Der diesem Browser liegt - ein Download von 759 MB startet nie von allein. Der
Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell loescht, Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell löscht,
bekommt es nicht sofort wieder heruntergeladen. bekommt es nicht sofort wieder heruntergeladen.
### Wo das Modell liegt und wie lange ### Wo das Modell liegt und wie lange
Nicht in Cookies - dort passen nur wenige Kilobyte. Die Dateien liegen im Nicht in Cookies - dort passen nur wenige Kilobyte. Die Dateien liegen im
**Cache-Speicher der Seite** (`caches`, Name `transformers-cache`), der **Cache-Speicher der Seite** (`caches`, Name `transformers-cache`), der
Sitzungen und Browserneustarts ueberlebt. Beim Laden bittet die App zusaetzlich Sitzungen und Browserneustarts überlebt. Beim Laden bittet die App zusätzlich
per `navigator.storage.persist()` um dauerhaften Speicher; damit darf der Browser per `navigator.storage.persist()` um dauerhaften Speicher; damit darf der Browser
die Dateien bei Platznot nicht mehr selbst wegraeumen. Chrome entscheidet darueber die Dateien bei Platznot nicht mehr selbst wegräumen. Chrome entscheidet darüber
nach eigenen Kriterien und lehnt haeufig ab - dann funktioniert alles genauso, nur nach eigenen Kriterien und lehnt häufig ab - dann funktioniert alles genauso, nur
ohne diese Zusicherung. ohne diese Zusicherung.
Geraet oder Browserprofil gewechselt? Dann faellt der Download erneut an; der Gerät oder Browserprofil gewechselt? Dann fällt der Download erneut an; der
Speicher gehoert zum Profil, nicht zum Benutzerkonto. Findet die App das Modell Speicher gehört zum Profil, nicht zum Benutzerkonto. Findet die App das Modell
nach dem Laden nicht im Cache, sagt sie es ausdruecklich. nach dem Laden nicht im Cache, sagt sie es ausdrücklich.
### Warum small und q4 - die Messungen ### Warum small und q4 - die Messungen
@@ -424,17 +424,17 @@ eine echte Stimme) auf einem Rechner mit NVIDIA-GPU, jeweils in q4:
| small | "auf Esfihana", "Der Tagessatz liegt bei 1100 Euro" | 299 MB | 10 s | | small | "auf Esfihana", "Der Tagessatz liegt bei 1100 Euro" | 299 MB | 10 s |
| **large-v3-turbo** | **"auf S4 HANA"**, "Der Tagessatz liegt bei 1100 Euro" | 759 MB | 13 s | | **large-v3-turbo** | **"auf S4 HANA"**, "Der Tagessatz liegt bei 1100 Euro" | 759 MB | 13 s |
Alle drei erkennen etwa 4x schneller als Echtzeit; auf Geraeten ohne WebGPU Alle drei erkennen etwa 4x schneller als Echtzeit; auf Geräten ohne WebGPU
verschiebt sich das Tempo erheblich. verschiebt sich das Tempo erheblich.
Festgelegt ist **large-v3-turbo**: nur dieses Modell bekommt Fachbegriffe wie Festgelegt ist **large-v3-turbo**: nur dieses Modell bekommt Fachbegriffe wie
"S4 HANA" richtig, und genau daran scheitern die kleineren im Alltag. Die 759 MB "S4 HANA" richtig, und genau daran scheitern die kleineren im Alltag. Die 759 MB
fallen einmal pro Browser an - danach liegt das Modell im Cache-Speicher der fallen einmal pro Browser an - danach liegt das Modell im Cache-Speicher der
Seite und ueberlebt Browserneustarts (gemessen: 762 MB, zweite Sitzung ohne Seite und überlebt Browserneustarts (gemessen: 762 MB, zweite Sitzung ohne
jeden Download bereit). jeden Download bereit).
`small` ist der Kompromiss: turbo erkennt besser, wird vom Browser aber nicht `small` ist der Kompromiss: turbo erkennt besser, wird vom Browser aber nicht
zwischengespeichert (siehe Groessengrenzen unten) und laedt darum bei jedem Start zwischengespeichert (siehe Größengrenzen unten) und lädt darum bei jedem Start
erneut 759 MB. erneut 759 MB.
### Welche Genauigkeit auf welchem Rechenweg ### Welche Genauigkeit auf welchem Rechenweg
@@ -443,41 +443,41 @@ Deshalb ist `q4` festgelegt:
| | q4 | q8 | fp16 | fp32 | | | q4 | q8 | fp16 | fp32 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| **WebGPU** | tiny 1,4 s · small 1,9 s · turbo 3,8 s | Kauderwelsch oder keine Antwort | tiny laeuft, small blockiert den Tab | Einzeldatei zu gross | | **WebGPU** | tiny 1,4 s · small 1,9 s · turbo 3,8 s | Kauderwelsch oder keine Antwort | tiny läuft, small blockiert den Tab | Einzeldatei zu groß |
| **WASM** | laeuft | small 10,3 s | ungetestet | Einzeldatei zu gross | | **WASM** | läuft | small 10,3 s | ungetestet | Einzeldatei zu groß |
Der wichtigste Grund fuer diese Beschraenkung: bei `small` in fp16 auf WebGPU Der wichtigste Grund für diese Beschränkung: bei `small` in fp16 auf WebGPU
reagiert der ganze Browser-Tab nicht mehr - **kein Timeout in der Seite kann das reagiert der ganze Browser-Tab nicht mehr - **kein Timeout in der Seite kann das
abfangen**, weil das Skript selbst nicht mehr laeuft. abfangen**, weil das Skript selbst nicht mehr läuft.
Genau deshalb ist die Auswahl verschwunden: `q4` steht fest, und damit kann diese Genau deshalb ist die Auswahl verschwunden: `q4` steht fest, und damit kann diese
Klasse von Fehlern nicht mehr auftreten. Als Netz bleibt trotzdem eine Frist pro Klasse von Fehlern nicht mehr auftreten. Als Netz bleibt trotzdem eine Frist pro
Erkennung und eine Haenger-Erkennung beim Laden - falls ein Geraet trotzdem etwas Erkennung und eine Hänger-Erkennung beim Laden - falls ein Gerät trotzdem etwas
Unerwartetes tut. Unerwartetes tut.
Zusaetzlich hat jede Erkennung eine Frist (15x Aufnahmedauer, mindestens 45 s, Zusätzlich hat jede Erkennung eine Frist (15x Aufnahmedauer, mindestens 45 s,
hoechstens 5 min). Laeuft sie ab, wird der Worker verworfen und der Fehler höchstens 5 min). Läuft sie ab, wird der Worker verworfen und der Fehler
gemeldet, statt dauerhaft "Erkenne..." zu zeigen. gemeldet, statt dauerhaft "Erkenne..." zu zeigen.
### Die Groessengrenze des Browsers ### Die Größengrenze des Browsers
Nicht die Gesamtgroesse entscheidet, sondern die **groesste Einzeldatei** - der Nicht die Gesamtgröße entscheidet, sondern die **größte Einzeldatei** - der
Browser muss sie in einem zusammenhaengenden Speicherblock halten. Ueber etwa Browser muss sie in einem zusammenhängenden Speicherblock halten. Über etwa
500 MB bricht das Laden mit "Array buffer allocation failed" ab. Belege: 425 MB 500 MB bricht das Laden mit "Array buffer allocation failed" ab. Belege: 425 MB
(turbo in q4) laden und laufen, 688 MB (turbo in fp32) scheitern. (turbo in q4) laden und laufen, 688 MB (turbo in fp32) scheitern.
Genau daran scheitern fuer turbo alle Genauigkeiten ausser q4 - ein weiterer Genau daran scheitern für turbo alle Genauigkeiten ausser q4 - ein weiterer
Grund, warum es keine Auswahl gibt. Grund, warum es keine Auswahl gibt.
Die Downloadgroesse im Reiter kommt live von Hugging Face Die Downloadgröße im Reiter kommt live von Hugging Face
(`src/lib/whisperModels.ts`), damit dort eine echte Zahl steht. (`src/lib/whisperModels.ts`), damit dort eine echte Zahl steht.
> **Korrektur:** In einer frueheren Fassung stand hier eine zweite Grenze von > **Korrektur:** In einer früheren Fassung stand hier eine zweite Grenze von
> etwa 300 MB pro Cache-Eintrag, oberhalb derer das Modell angeblich nicht > etwa 300 MB pro Cache-Eintrag, oberhalb derer das Modell angeblich nicht
> zwischengespeichert wird. Das war falsch - ein Artefakt der Testumgebung. > zwischengespeichert wird. Das war falsch - ein Artefakt der Testumgebung.
> Von Playwright gestartete Chrome-Instanzen schreiben den Cache-Speicher nicht, > Von Playwright gestartete Chrome-Instanzen schreiben den Cache-Speicher nicht,
> ein normal gestartetes Chrome dagegen sehr wohl: turbo/q4 liegt dort mit > ein normal gestartetes Chrome dagegen sehr wohl: turbo/q4 liegt dort mit
> 762 MB im Cache und ueberlebt einen Browserneustart. Deshalb prueft > 762 MB im Cache und überlebt einen Browserneustart. Deshalb prüft
> `scratchpad/realchrome.mjs` die Persistenz gegen ein normal gestartetes > `scratchpad/realchrome.mjs` die Persistenz gegen ein normal gestartetes
> Chrome und nicht gegen den Automatisierungskontext. > Chrome und nicht gegen den Automatisierungskontext.
@@ -490,59 +490,59 @@ Die Downloadgroesse im Reiter kommt live von Hugging Face
Solange nichts geladen ist, erscheint im Opportunity-Formular ein Hinweis mit Solange nichts geladen ist, erscheint im Opportunity-Formular ein Hinweis mit
Knopf **Zu Whisper**; die einzelnen Mikrofon-Buttons werden zu *Diktat Knopf **Zu Whisper**; die einzelnen Mikrofon-Buttons werden zu *Diktat
einrichten*. Beides schliesst den Dialog und springt in den Reiter. Auf Geraeten einrichten*. Beides schließt den Dialog und springt in den Reiter. Auf Geräten
ohne Mikrofon oder ohne HTTPS erscheint gar kein Diktat. ohne Mikrofon oder ohne HTTPS erscheint gar kein Diktat.
## Kalender ## Kalender
Ein gemeinsamer Teamkalender: alle Benutzer sehen dieselben Termine, jeder darf Ein gemeinsamer Teamkalender: alle Benutzer sehen dieselben Termine, jeder darf
Eintraege anlegen. Einträge anlegen.
* **Ansichten** Monat, Woche, Tag; `Heute` und die Pfeile blaettern. Klick auf * **Ansichten** Monat, Woche, Tag; `Heute` und die Pfeile blättern. Klick auf
einen Monatstag oder eine Stundenzelle legt direkt einen Termin an, Klick auf einen Monatstag oder eine Stundenzelle legt direkt einen Termin an, Klick auf
einen Wochentagskopf springt in die Tagesansicht. einen Wochentagskopf springt in die Tagesansicht.
* **Farben** Jeder Termin hat die Farbe seines Erstellers. Die Farbe kommt aus * **Farben** Jeder Termin hat die Farbe seines Erstellers. Die Farbe kommt aus
`crm_users.color`; ist sie leer, wird sie stabil aus der Benutzer-ID `crm_users.color`; ist sie leer, wird sie stabil aus der Benutzer-ID
abgeleitet (Palette in `src/types.ts`). Aenderbar unter *Einstellungen → Mein abgeleitet (Palette in `src/types.ts`). Änderbar unter *Einstellungen → Mein
Konto* und - fuer Admins fuer alle - in der Nutzerverwaltung. Die Legende Konto* und - für Admins für alle - in der Nutzerverwaltung. Die Legende
unter dem Kalender zeigt, welche Farbe zu wem gehoert. unter dem Kalender zeigt, welche Farbe zu wem gehört.
* **Teilnehmer** Ein Termin kann beliebig viele Benutzer haben; sie erscheinen * **Teilnehmer** Ein Termin kann beliebig viele Benutzer haben; sie erscheinen
mit Foto - oder, wenn keines hinterlegt ist, als Initiale auf ihrer mit Foto - oder, wenn keines hinterlegt ist, als Initiale auf ihrer
Kalenderfarbe - und sind anklickbar. Kalenderfarbe - und sind anklickbar.
* **@-Mentions** In der Beschreibung `@` tippen, um Kontakte *und* Benutzer zu * **@-Mentions** In der Beschreibung `@` tippen, um Kontakte *und* Benutzer zu
markieren (Pfeiltasten, Enter/Tab zum Uebernehmen). Gespeichert wird ein Token markieren (Pfeiltasten, Enter/Tab zum Übernehmen). Gespeichert wird ein Token
`@[Anzeigename](lead:12)` bzw. `@[Anzeigename](user:3)`. In der Leseansicht `@[Anzeigename](lead:12)` bzw. `@[Anzeigename](user:3)`. In der Leseansicht
sind Mentions klickbar: Kontakte oeffnen das Kontaktfenster, Benutzer ein sind Mentions klickbar: Kontakte öffnen das Kontaktfenster, Benutzer ein
Detailfenster mit Farbe, naechsten Terminen und zugeordneten Kontakten. Detailfenster mit Farbe, nächsten Terminen und zugeordneten Kontakten.
Existiert die Person nicht mehr, bleibt der Name als grauer Text stehen. Existiert die Person nicht mehr, bleibt der Name als grauer Text stehen.
* **Naechste Termine** Rechts neben dem Kalender, nach Tagen gruppiert und * **Nächste Termine** Rechts neben dem Kalender, nach Tagen gruppiert und
zeitlich sortiert - der naechste anstehende Termin steht oben. Vergangene zeitlich sortiert - der nächste anstehende Termin steht oben. Vergangene
Termine fallen aus der Liste, bleiben im Kalender aber erhalten. Termine fallen aus der Liste, bleiben im Kalender aber erhalten.
## Fotos ## Fotos
Fotos von Kontakten und Benutzern liegen als Data-URL in der Datenbank Fotos von Kontakten und Benutzern liegen als Data-URL in der Datenbank
(`crm_leads.avatar`, `crm_users.avatar`). Damit das die Nutzlast nicht aufblaeht, (`crm_leads.avatar`, `crm_users.avatar`). Damit das die Nutzlast nicht aufbläht,
verkleinert der Browser jedes Bild **vor** dem Upload verkleinert der Browser jedes Bild **vor** dem Upload
(`src/lib/imageCompress.ts`): (`src/lib/imageCompress.ts`):
* mittiger Ausschnitt auf ein Quadrat von 256 px - mehr braucht keine Ansicht, * mittiger Ausschnitt auf ein Quadrat von 256 px - mehr braucht keine Ansicht,
die groesste ist 80 px die größte ist 80 px
* WebP mit Qualitaet 0,82, Fallback JPEG, dann PNG * WebP mit Qualität 0,82, Fallback JPEG, dann PNG
* aus einem 2,9-MB-Foto werden so einige Kilobyte * aus einem 2,9-MB-Foto werden so einige Kilobyte
Das Backend akzeptiert nur Bild-Data-URLs und http(s)-Adressen und begrenzt die Das Backend akzeptiert nur Bild-Data-URLs und http(s)-Adressen und begrenzt die
Laenge; die Quelldatei darf bis 25 MB gross sein, weil sie ohnehin Länge; die Quelldatei darf bis 25 MB groß sein, weil sie ohnehin
komprimiert wird. komprimiert wird.
Benutzerfotos pflegt jeder fuer sich unter *Einstellungen → Mein Benutzerfotos pflegt jeder für sich unter *Einstellungen → Mein
Konto*, Admins fuer alle in der Nutzerverwaltung. Angezeigt werden sie in der Konto*, Admins für alle in der Nutzerverwaltung. Angezeigt werden sie in der
Sidebar, bei Terminteilnehmern und Erstellern, im Benutzer-Detailfenster, in den Sidebar, bei Terminteilnehmern und Erstellern, im Benutzer-Detailfenster, in den
@-Vorschlaegen, in der Nutzerverwaltung und als Benutzerknoten im @-Vorschlägen, in der Nutzerverwaltung und als Benutzerknoten im
Netzwerkdiagramm. Ohne Foto erscheint ueberall die Initiale auf der Netzwerkdiagramm. Ohne Foto erscheint überall die Initiale auf der
Kalenderfarbe (`UserAvatar`). Kalenderfarbe (`UserAvatar`).
Ueberlappende Termine teilen sich im Zeitraster die Spaltenbreite, mehrtaegige Überlappende Termine teilen sich im Zeitraster die Spaltenbreite, mehrtägige
Termine erscheinen an jedem betroffenen Tag, und eine rote Linie markiert die Termine erscheinen an jedem betroffenen Tag, und eine rote Linie markiert die
aktuelle Uhrzeit. aktuelle Uhrzeit.
@@ -550,14 +550,14 @@ aktuelle Uhrzeit.
Kraftgerichtetes Layout (`d3-force`), gerendert als SVG. Kraftgerichtetes Layout (`d3-force`), gerendert als SVG.
* **Kreise** = Kontakte, Farbe nach Status, Groesse nach Anzahl Verbindungen * **Kreise** = Kontakte, Farbe nach Status, Größe nach Anzahl Verbindungen
* **Quadrate** = Benutzer, die Kontakte angelegt haben * **Quadrate** = Benutzer, die Kontakte angelegt haben
* **Gruene Linie** = Kontakt &harr; Kontakt (Dicke nach Verbindungsstaerke) * **Grüne Linie** = Kontakt &harr; Kontakt (Dicke nach Verbindungsstärke)
* **Graue gestrichelte Linie** = Ersteller &rarr; Kontakt * **Graue gestrichelte Linie** = Ersteller &rarr; Kontakt
Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt auf den Cursor, Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt auf den Cursor,
Knoten ziehen fixiert ihn (gruener Punkt), Doppelklick loest die Fixierung. Knoten ziehen fixiert ihn (grüner Punkt), Doppelklick löst die Fixierung.
Ueber *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen; Über *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen;
Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter. Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter.
## Lokale Entwicklung ## Lokale Entwicklung
@@ -569,7 +569,7 @@ npm start # Backend :5006
cd .. && npm run dev # Frontend :3100, /api wird geproxied cd .. && npm run dev # Frontend :3100, /api wird geproxied
``` ```
Fuer lokale Entwicklung braucht es eine erreichbare MariaDB mit der Datenbank Für lokale Entwicklung braucht es eine erreichbare MariaDB mit der Datenbank
`perinco_CRM`; das Schema legt das Backend beim Start selbst an. Die `perinco_CRM`; das Schema legt das Backend beim Start selbst an. Die
Scanner-Datenbank `perinco_scanner` legt es komplett selbst an, inklusive Scanner-Datenbank `perinco_scanner` legt es komplett selbst an, inklusive
Tabellen, Portalen und Standardeinstellungen. Tabellen, Portalen und Standardeinstellungen.
@@ -611,19 +611,19 @@ Abschnitt *Playwright* oben.
> **Reihenfolge beachten:** Der Import schreibt `search_interval` aus der alten > **Reihenfolge beachten:** Der Import schreibt `search_interval` aus der alten
> App (15 Minuten) in die Einstellungen. Der laufende Scheduler bemerkt die > App (15 Minuten) in die Einstellungen. Der laufende Scheduler bemerkt die
> Aenderung binnen einer Minute und plant sich neu - beim Rollout hat er deshalb > Änderung binnen einer Minute und plant sich neu - beim Rollout hat er deshalb
> direkt nach dem Import einen Scan gestartet, den der folgende Restart mitten im > direkt nach dem Import einen Scan gestartet, den der folgende Restart mitten im
> Scrapen abgeschossen hat (`Target page ... has been closed` im Log, harmlos). > Scrapen abgeschossen hat (`Target page ... has been closed` im Log, harmlos).
> Wer das vermeiden will, macht Schritt 3 vor Schritt 2. > Wer das vermeiden will, macht Schritt 3 vor Schritt 2.
Das hoehere `MemoryMax` (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet Das höhere `MemoryMax` (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet
Chromium als Kindprozess in derselben cgroup, mit 512M wuerde der OOM-Killer das Chromium als Kindprozess in derselben cgroup, mit 512M würde der OOM-Killer das
Backend mitten im Scrapen beenden. `deploy.ps1` laedt die Unit nicht mit hoch - Backend mitten im Scrapen beenden. `deploy.ps1` lädt die Unit nicht mit hoch -
daher der `cp` oben. daher der `cp` oben.
> **RAM im Parallelbetrieb:** Im schlechtesten Fall scrapen beide Scanner > **RAM im Parallelbetrieb:** Im schlechtesten Fall scrapen beide Scanner
> gleichzeitig, also laufen zwei Chromium-Instanzen. `SCANNER_SCHEDULER_OFFSET` > gleichzeitig, also laufen zwei Chromium-Instanzen. `SCANNER_SCHEDULER_OFFSET`
> verschiebt die Laeufe gegeneinander, garantiert aber nichts - der alte Dienst > verschiebt die Läufe gegeneinander, garantiert aber nichts - der alte Dienst
> hat seinen eigenen Zeitplan. Auf einem Server mit knappem RAM und vollem Swap > hat seinen eigenen Zeitplan. Auf einem Server mit knappem RAM und vollem Swap
> ist das der Punkt, den man nach dem ersten Tag im Journal nachsieht. > ist das der Punkt, den man nach dem ersten Tag im Journal nachsieht.
@@ -665,4 +665,4 @@ in `deploy/pcrm.perinco.de.conf` und den certbot-Aufruf anpassen.
Beim ersten Backend-Start wird ein Administrator aus den `ADMIN_*`-Variablen in Beim ersten Backend-Start wird ein Administrator aus den `ADMIN_*`-Variablen in
`server/.env` angelegt - aber nur, wenn `crm_users` leer ist. Das Passwort ist `server/.env` angelegt - aber nur, wenn `crm_users` leer ist. Das Passwort ist
danach ausschliesslich ueber die Nutzerverwaltung bzw. *Mein Konto* aenderbar. danach ausschließlich über die Nutzerverwaltung bzw. *Mein Konto* änderbar.
+16 -16
View File
@@ -2,7 +2,7 @@
## Bausteine ## Bausteine
Drei Teile, keiner davon spricht direkt mit einem anderen ausser ueber HTTP bzw. Drei Teile, keiner davon spricht direkt mit einem anderen ausser über HTTP bzw.
SQL: SQL:
| Teil | Technik | Wo | | Teil | Technik | Wo |
@@ -11,8 +11,8 @@ SQL:
| Backend | Express 4, mysql2 (CRM), Sequelize (Scanner) | `/var/www/perinco_CRM/server`, `127.0.0.1:5006` | | Backend | Express 4, mysql2 (CRM), Sequelize (Scanner) | `/var/www/perinco_CRM/server`, `127.0.0.1:5006` |
| Datenbank | MariaDB 10.11 | `perinco_CRM` und `perinco_scanner` | | Datenbank | MariaDB 10.11 | `perinco_CRM` und `perinco_scanner` |
Der Browser spricht **nie** direkt mit MariaDB - deshalb ueberhaupt ein Backend. Der Browser spricht **nie** direkt mit MariaDB - deshalb überhaupt ein Backend.
Das Backend bindet ausschliesslich auf `127.0.0.1` und ist von aussen nur ueber Das Backend bindet ausschließlich auf `127.0.0.1` und ist von außen nur über
den Apache-Reverse-Proxy erreichbar. den Apache-Reverse-Proxy erreichbar.
## Topologie ## Topologie
@@ -33,20 +33,20 @@ flowchart LR
class U2,A2,E2,S2 alt class U2,A2,E2,S2 alt
``` ```
Der Scanner im CRM haengt unter `/api/scanner` und braucht damit **keine eigene Der Scanner im CRM hängt unter `/api/scanner` und braucht damit **keine eigene
Apache-Regel**: alles laeuft ueber den bestehenden `/api`-Proxy. Dasselbe gilt fuer Apache-Regel**: alles läuft über den bestehenden `/api`-Proxy. Dasselbe gilt für
die Postfachanbindung unter `/api/mail` (siehe die Postfachanbindung unter `/api/mail` (siehe
[11 Postfachanbindung](11-postfach.md)). [11 Postfachanbindung](11-postfach.md)).
## Ausgehende Verbindungen ## Ausgehende Verbindungen
Das Backend spricht von sich aus mit drei fremden Diensten. Alle drei sind Das Backend spricht von sich aus mit drei fremden Diensten. Alle drei sind
optional - fehlen die Zugangsdaten, laeuft die Anwendung ohne den jeweiligen optional - fehlen die Zugangsdaten, läuft die Anwendung ohne den jeweiligen
Teil weiter. Teil weiter.
| Ziel | Wofuer | Ausgeloest von | | Ziel | Wofür | Ausgelöst von |
| --- | --- | --- | | --- | --- | --- |
| Projektboersen | Scraping der Projektangebote | Scanner-Scheduler | | Projektbörsen | Scraping der Projektangebote | Scanner-Scheduler |
| `api.openai.com` | Bewertung Lebenslauf gegen Projekt | Scanner-Pipeline | | `api.openai.com` | Bewertung Lebenslauf gegen Projekt | Scanner-Pipeline |
| `login.microsoftonline.com`, `graph.microsoft.com` | Postfach `crm@perinco.de` lesen | Mail-Scheduler | | `login.microsoftonline.com`, `graph.microsoft.com` | Postfach `crm@perinco.de` lesen | Mail-Scheduler |
@@ -80,10 +80,10 @@ sequenceDiagram
E-->>B: JSON E-->>B: JSON
``` ```
Bei `401` loescht das Frontend das Token und springt zurueck auf den Bei `401` löscht das Frontend das Token und springt zurück auf den
Anmeldeschirm (`setUnauthorizedHandler` in `src/lib/api.ts`). Das Token wird bei Anmeldeschirm (`setUnauthorizedHandler` in `src/lib/api.ts`). Das Token wird bei
jeder Anfrage neu gegen die Datenbank geprueft - ein deaktivierter Benutzer ist jeder Anfrage neu gegen die Datenbank geprüft - ein deaktivierter Benutzer ist
sofort draussen, ohne auf den Ablauf des Tokens zu warten. sofort draußen, ohne auf den Ablauf des Tokens zu warten.
## Rollen ## Rollen
@@ -98,19 +98,19 @@ Bei den Mails darf jede Rolle lesen und zuordnen - `crm@perinco.de` ist ein
gemeinsames Vertriebspostfach. Einrichten, Verbindungstest und manueller Abgleich gemeinsames Vertriebspostfach. Einrichten, Verbindungstest und manueller Abgleich
sind Administratoren vorbehalten. sind Administratoren vorbehalten.
Die Rollenpruefung sitzt **doppelt**: das Menue blendet aus, was nicht erlaubt Die Rollenprüfung sitzt **doppelt**: das Menü blendet aus, was nicht erlaubt
ist, und das Backend weist Zugriffe zusaetzlich mit `403` ab (`requireAdmin`). ist, und das Backend weist Zugriffe zusätzlich mit `403` ab (`requireAdmin`).
Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen
fuer Netzwerkdiagramm, Zustaendigkeiten, @-Mentions, Teilnehmerauswahl und für Netzwerkdiagramm, Zuständigkeiten, @-Mentions, Teilnehmerauswahl und
Kalenderfarben. Passwort-Hashes liefert der Endpunkt nie. Kalenderfarben. Passwort-Hashes liefert der Endpunkt nie.
Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator
degradiert, deaktiviert oder geloescht wird. degradiert, deaktiviert oder gelöscht wird.
## Belegte Ports auf dem Server ## Belegte Ports auf dem Server
Der Rechner hostet weitere Dienste (n8n, Docker, ~15 Apache-Vhosts). Vor jeder Der Rechner hostet weitere Dienste (n8n, Docker, ~15 Apache-Vhosts). Vor jeder
Portbelegung `ss -tlnp` pruefen. Relevant hier: Portbelegung `ss -tlnp` prüfen. Relevant hier:
| Port | Dienst | | Port | Dienst |
| --- | --- | | --- | --- |
+34 -34
View File
@@ -7,9 +7,9 @@ Zwei Datenbanken, absichtlich getrennt:
| `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine, Postfach, Einstellungen | `mysql2` direkt, Schema aus `server/src/schema.sql` | | `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine, Postfach, Einstellungen | `mysql2` direkt, Schema aus `server/src/schema.sql` |
| `perinco_scanner` | Suchagenten, Portale, Projekte, Treffer, Scanner-Einstellungen | Sequelize, Schema aus den Modellen (`sequelize.sync()`) | | `perinco_scanner` | Suchagenten, Portale, Projekte, Treffer, Scanner-Einstellungen | Sequelize, Schema aus den Modellen (`sequelize.sync()`) |
Die Trennung ist kein Zufall: die Scanner-Tabellen sind eine 1:1-Uebernahme aus Die Trennung ist kein Zufall: die Scanner-Tabellen sind eine 1:1-Übernahme aus
der eigenstaendigen App. Gleiche Tabellennamen, gleiche Spalten - nur in einer der eigenständigen App. Gleiche Tabellennamen, gleiche Spalten - nur in einer
eigenen Datenbank, damit beide Anwendungen parallel laufen koennen. eigenen Datenbank, damit beide Anwendungen parallel laufen können.
## perinco_CRM ## perinco_CRM
@@ -61,7 +61,7 @@ erDiagram
enum stage "Neu .. Verloren" enum stage "Neu .. Verloren"
tinyint probability "0-100" tinyint probability "0-100"
decimal daily_rate decimal daily_rate
decimal duration_days "Personentage, halbe moeglich" decimal duration_days "Personentage, halbe möglich"
date last_interaction_at date last_interaction_at
varchar next_step varchar next_step
int owner_id FK int owner_id FK
@@ -88,15 +88,15 @@ erDiagram
Vier Eigenheiten, die man kennen muss: Vier Eigenheiten, die man kennen muss:
- **`crm_leads` fuehrt zwei Bestaende.** `mail_pool = 0` ist der gepflegte - **`crm_leads` führt zwei Bestände.** `mail_pool = 0` ist der gepflegte
Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des
Postfachabgleichs. Nur der Kontaktbestand erscheint in Kontaktliste, Postfachabgleichs. Nur der Kontaktbestand erscheint in Kontaktliste,
Opportunities, Kalender und Netzwerk; der Vorbereich hat seine eigene Ansicht. Opportunities, Kalender und Netzwerk; der Vorbereich hat seine eigene Ansicht.
Eine Spalte statt einer zweiten Tabelle, damit `crm_mail_links` und Eine Spalte statt einer zweiten Tabelle, damit `crm_mail_links` und
`crm_mail_contact_candidates` fuer beide Bestaende gelten `crm_mail_contact_candidates` für beide Bestände gelten
([11 Postfachanbindung](11-postfach.md)). ([11 Postfachanbindung](11-postfach.md)).
- **`tags` ist Text, kein JSON-Typ.** Bewusst, damit es auf aelteren - **`tags` ist Text, kein JSON-Typ.** Bewusst, damit es auf älteren
MariaDB-Versionen ohne JSON-Funktionen laeuft. Das Backend serialisiert. MariaDB-Versionen ohne JSON-Funktionen läuft. Das Backend serialisiert.
- **`crm_lead_connections` speichert das Paar sortiert** (kleinere Id in - **`crm_lead_connections` speichert das Paar sortiert** (kleinere Id in
`from_lead_id`). Der Unique-Key verhindert damit auch die Umkehrung als `from_lead_id`). Der Unique-Key verhindert damit auch die Umkehrung als
Duplikat, obwohl die Verbindung ungerichtet gedacht ist. Duplikat, obwohl die Verbindung ungerichtet gedacht ist.
@@ -106,7 +106,7 @@ Vier Eigenheiten, die man kennen muss:
### Postfach crm@perinco.de ### Postfach crm@perinco.de
Diese Tabellen liegen in derselben Datenbank, haengen aber nur ueber Diese Tabellen liegen in derselben Datenbank, hängen aber nur über
`crm_mail_links` und `crm_mail_contact_candidates.lead_id` am Rest. Fachliche `crm_mail_links` und `crm_mail_contact_candidates.lead_id` am Rest. Fachliche
Beschreibung in [11 Postfachanbindung](11-postfach.md). Beschreibung in [11 Postfachanbindung](11-postfach.md).
@@ -116,21 +116,21 @@ erDiagram
crm_mail_messages ||--o{ crm_mail_contact_candidates : liefert crm_mail_messages ||--o{ crm_mail_contact_candidates : liefert
crm_leads ||--o{ crm_mail_links : "lead_id" crm_leads ||--o{ crm_mail_links : "lead_id"
crm_opportunities ||--o{ crm_mail_links : "opportunity_id" crm_opportunities ||--o{ crm_mail_links : "opportunity_id"
crm_leads ||--o{ crm_mail_contact_candidates : "lead_id, nach Uebernahme" crm_leads ||--o{ crm_mail_contact_candidates : "lead_id, nach Übernahme"
crm_mail_messages { crm_mail_messages {
int id PK int id PK
varchar graph_id "Id aus Microsoft Graph" varchar graph_id "Id aus Microsoft Graph"
char graph_id_hash UK "SHA-256 hex, traegt die Eindeutigkeit" char graph_id_hash UK "SHA-256 hex, trägt die Eindeutigkeit"
varchar conversation_id "Unterhaltung" varchar conversation_id "Unterhaltung"
varchar subject varchar subject
varchar from_email varchar from_email
longtext to_json "[{name,email}] als Text" longtext to_json "[{name,email}] als Text"
datetime received_at "UTC" datetime received_at "UTC"
longtext body_text "aus HTML gewandelt, Zeilen erhalten" longtext body_text "aus HTML gewandelt, Zeilen erhalten"
longtext body_html "entschaerft fuer die Anzeige" longtext body_html "entschärft für die Anzeige"
tinyint has_attachments tinyint has_attachments
tinyint is_deleted "im Postfach geloescht" tinyint is_deleted "im Postfach gelöscht"
} }
crm_mail_links { crm_mail_links {
int id PK int id PK
@@ -151,13 +151,13 @@ erDiagram
varchar company varchar company
enum origin "absender | empfaenger | signatur | text" enum origin "absender | empfaenger | signatur | text"
tinyint confidence "0-100" tinyint confidence "0-100"
enum status "offen | uebernommen | verworfen | dublette" enum status "offen | übernommen | verworfen | dublette"
int lead_id FK "gesetzt ab uebernommen" int lead_id FK "gesetzt ab übernommen"
} }
crm_mail_sync_state { crm_mail_sync_state {
tinyint id PK "immer 1" tinyint id PK "immer 1"
varchar mailbox varchar mailbox
text delta_link "naechster Graph-Aufruf" text delta_link "nächster Graph-Aufruf"
tinyint has_more "nextLink offen" tinyint has_more "nextLink offen"
datetime last_run_at datetime last_run_at
datetime last_success_at datetime last_success_at
@@ -171,20 +171,20 @@ erDiagram
Vier Eigenheiten: Vier Eigenheiten:
- **Die Graph-ID hat einen Hash daneben.** Sie ist mit ueber 150 Zeichen zu lang - **Die Graph-ID hat einen Hash daneben.** Sie ist mit über 150 Zeichen zu lang
fuer einen Unique-Index unter `utf8mb4`; die Eindeutigkeit traegt deshalb für einen Unique-Index unter `utf8mb4`; die Eindeutigkeit trägt deshalb
`graph_id_hash` (SHA-256 als Hex, `CHAR(64)`). `graph_id_hash` (SHA-256 als Hex, `CHAR(64)`).
- **`received_at` steht in UTC**, anders als `crm_events.starts_at` (lokale - **`received_at` steht in UTC**, anders als `crm_events.starts_at` (lokale
Wandzeit). Graph liefert UTC, und eine stille Umrechnung beim Speichern waere Wandzeit). Graph liefert UTC, und eine stille Umrechnung beim Speichern wäre
eine Fehlerquelle. Das Frontend rechnet um (`parseMailDate` in eine Fehlerquelle. Das Frontend rechnet um (`parseMailDate` in
`src/lib/mailApi.ts`). `src/lib/mailApi.ts`).
- **`crm_mail_links` erlaubt NULL in beiden Fremdschluesseln.** MariaDB laesst in - **`crm_mail_links` erlaubt NULL in beiden Fremdschlüsseln.** MariaDB lässt in
einem Unique-Index mehrere NULL zu - die beiden Schluessel sperren damit genau einem Unique-Index mehrere NULL zu - die beiden Schlüssel sperren damit genau
die Doppelung desselben Paares, und eine Mail kann an mehreren Kontakten und die Doppelung desselben Paares, und eine Mail kann an mehreren Kontakten und
mehreren Opportunities haengen. mehreren Opportunities hängen.
- **`crm_settings` enthaelt keine Geheimnisse.** Zugangsdaten fuer Microsoft 365 - **`crm_settings` enthält keine Geheimnisse.** Zugangsdaten für Microsoft 365
stehen ausschliesslich in `server/.env`, damit ein Datenbank-Dump keine Secrets stehen ausschließlich in `server/.env`, damit ein Datenbank-Dump keine Secrets
enthaelt. enthält.
## perinco_scanner ## perinco_scanner
@@ -197,7 +197,7 @@ erDiagram
agents { agents {
int id PK int id PK
varchar name varchar name
varchar email "fuer Benachrichtigungen" varchar email "für Benachrichtigungen"
longtext cv_text "aus dem PDF extrahiert" longtext cv_text "aus dem PDF extrahiert"
varchar cv_file_path "Pfad im Upload-Verzeichnis" varchar cv_file_path "Pfad im Upload-Verzeichnis"
varchar keywords "kommagetrennt, Suchbegriffe" varchar keywords "kommagetrennt, Suchbegriffe"
@@ -207,7 +207,7 @@ erDiagram
projects { projects {
int id PK int id PK
varchar portal "freelancermap.de, hays.de, ..." varchar portal "freelancermap.de, hays.de, ..."
varchar portal_project_id "Id der Boerse" varchar portal_project_id "Id der Börse"
varchar title varchar title
longtext description longtext description
varchar location varchar location
@@ -244,12 +244,12 @@ erDiagram
- **`matches` hat einen Unique-Key auf `(agent_id, project_id)`.** Dadurch wird - **`matches` hat einen Unique-Key auf `(agent_id, project_id)`.** Dadurch wird
jedes Projekt pro Agent genau einmal von OpenAI bewertet - das ist der Grund, jedes Projekt pro Agent genau einmal von OpenAI bewertet - das ist der Grund,
warum ein zweiter Scan-Lauf ueber dieselben Projekte nichts kostet. warum ein zweiter Scan-Lauf über dieselben Projekte nichts kostet.
- **`projects` wird nach 7 Tagen aufgeraeumt** (`cleanupOldProjects`), die - **`projects` wird nach 7 Tagen aufgeräumt** (`cleanupOldProjects`), die
Treffer verschwinden per `ON DELETE CASCADE` mit. Treffer verschwinden per `ON DELETE CASCADE` mit.
- **`settings` ist ein Schluessel-Wert-Speicher.** Geheimnisse (`openai_api_key`, - **`settings` ist ein Schlüssel-Wert-Speicher.** Geheimnisse (`openai_api_key`,
`smtp_pass`) liefert die API nur maskiert als `********` aus; ein leeres oder `smtp_pass`) liefert die API nur maskiert als `********` aus; ein leeres oder
maskiertes Feld beim Speichern laesst den gespeicherten Wert unberuehrt. maskiertes Feld beim Speichern lässt den gespeicherten Wert unberührt.
## Statuswege ## Statuswege
@@ -259,16 +259,16 @@ stateDiagram-v2
new --> reviewed : "Als Gesehen markieren" new --> reviewed : "Als Gesehen markieren"
new --> applied : "Als Beworben markieren" new --> applied : "Als Beworben markieren"
reviewed --> applied reviewed --> applied
applied --> new : "Zurueck auf Neu" applied --> new : "Zurück auf Neu"
reviewed --> new reviewed --> new
new --> rejected : "Passt nicht" new --> rejected : "Passt nicht"
reviewed --> rejected reviewed --> rejected
applied --> rejected applied --> rejected
rejected --> new : "Zurueck ins Dashboard" rejected --> new : "Zurück ins Dashboard"
rejected --> [*] : loeschen rejected --> [*] : loeschen
new --> [*] : loeschen new --> [*] : loeschen
``` ```
`rejected` ist der einzige Status, der aus dem Dashboard verschwindet und `rejected` ist der einzige Status, der aus dem Dashboard verschwindet und
stattdessen im Reiter *Unpassend* auftaucht. Solche Treffer zaehlen auch in den stattdessen im Reiter *Unpassend* auftaucht. Solche Treffer zählen auch in den
Kennzahlen nicht mit. Kennzahlen nicht mit.
+39 -39
View File
@@ -1,8 +1,8 @@
# 03 Projekt Scanner # 03 Projekt Scanner
Der Scanner durchsucht Projektboersen nach Auftraegen und bewertet jeden Fund mit Der Scanner durchsucht Projektbörsen nach Aufträgen und bewertet jeden Fund mit
OpenAI gegen die Lebenslaeufe der Berater. Er ist eine vollstaendige Portierung OpenAI gegen die Lebensläufe der Berater. Er ist eine vollständige Portierung
der eigenstaendigen App und laeuft parallel zu ihr. der eigenständigen App und läuft parallel zu ihr.
## Ein Suchlauf ## Ein Suchlauf
@@ -43,15 +43,15 @@ sequenceDiagram
Zwei Sparmassnahmen stecken bewusst **vor** dem OpenAI-Aufruf: der vorhandene Zwei Sparmassnahmen stecken bewusst **vor** dem OpenAI-Aufruf: der vorhandene
Treffer (Unique-Key `agent_id, project_id`) und die Ausschlussbegriffe. Ein Treffer (Unique-Key `agent_id, project_id`) und die Ausschlussbegriffe. Ein
Projekt, das „Arbeitnehmerueberlassung" enthaelt, kostet damit keinen Cent. Projekt, das „Arbeitnehmerüberlassung" enthält, kostet damit keinen Cent.
## Wann laeuft er ## Wann läuft er
```mermaid ```mermaid
flowchart TD flowchart TD
T["Cron-Tick, jede Minute"] --> A{"scanning_active?"} T["Cron-Tick, jede Minute"] --> A{"scanning_active?"}
A -->|nein| STOP1["nichts tun"] A -->|nein| STOP1["nichts tun"]
A -->|ja| B{"Intervall geaendert?"} A -->|ja| B{"Intervall geändert?"}
B -->|ja| RE["neu planen"] B -->|ja| RE["neu planen"]
B -->|nein| C{"Sperrfrist<br/>nur beim allerersten Start"} B -->|nein| C{"Sperrfrist<br/>nur beim allerersten Start"}
C -->|noch aktiv| STOP2["nichts tun"] C -->|noch aktiv| STOP2["nichts tun"]
@@ -59,7 +59,7 @@ flowchart TD
D -->|nein| STOP3["nichts tun"] D -->|nein| STOP3["nichts tun"]
D -->|ja| E{"07:00-21:59?"} D -->|ja| E{"07:00-21:59?"}
E -->|nein| STOP4["nichts tun"] E -->|nein| STOP4["nichts tun"]
E -->|ja| S["last_scan_at aus der DB lesen<br/>(faengt manuelle Laeufe mit ein)"] E -->|ja| S["last_scan_at aus der DB lesen<br/>(fängt manuelle Läufe mit ein)"]
S --> F{"Intervall seit<br/>letztem Lauf um?"} S --> F{"Intervall seit<br/>letztem Lauf um?"}
F -->|nein| STOP5["nichts tun"] F -->|nein| STOP5["nichts tun"]
F -->|ja| G["Pipeline starten"] F -->|ja| G["Pipeline starten"]
@@ -72,23 +72,23 @@ gleichzeitig los, was Rate-Limits und CAPTCHAs provoziert - und zwei Chromium
gleichzeitig belasten den Speicher doppelt. gleichzeitig belasten den Speicher doppelt.
Der Zeitpunkt des letzten Laufs steht in `settings.last_scan_at`. Das ist kein Der Zeitpunkt des letzten Laufs steht in `settings.last_scan_at`. Das ist kein
Luxus, sondern nach zwei beobachteten Fehlern noetig: Luxus, sondern nach zwei beobachteten Fehlern nötig:
> **Erster Fehler:** Der Versatz war ueber einen vorgealterten `lastRunTime` > **Erster Fehler:** Der Versatz war über einen vorgealterten `lastRunTime`
> gebaut. Ein Zeitstempel kann einen Lauf aber nur **vorziehen**, nie verzoegern - > gebaut. Ein Zeitstempel kann einen Lauf aber nur **vorziehen**, nie verzögern -
> bei Intervall 15 und Versatz 30 lief er sofort. Seither eine eigene Frist > bei Intervall 15 und Versatz 30 lief er sofort. Seither eine eigene Frist
> (`notBefore`). > (`notBefore`).
> >
> **Zweiter Fehler:** Diese Frist begann bei **jedem** Neustart von vorn. Zwei > **Zweiter Fehler:** Diese Frist begann bei **jedem** Neustart von vorn. Zwei
> Deployments im Abstand von zehn Minuten haben den faelligen Lauf zweimal um eine > Deployments im Abstand von zehn Minuten haben den fälligen Lauf zweimal um eine
> halbe Stunde verschoben - er fand nie statt. Wer oefter als alle 30 Minuten > halbe Stunde verschoben - er fand nie statt. Wer öfter als alle 30 Minuten
> deployt, haette einen Scanner, der nie automatisch sucht. Deshalb liegt der > deployt, hätte einen Scanner, der nie automatisch sucht. Deshalb liegt der
> Zeitpunkt jetzt in der Datenbank, und der Versatz greift nur, solange dort noch > Zeitpunkt jetzt in der Datenbank, und der Versatz greift nur, solange dort noch
> nichts steht. > nichts steht.
Der gespeicherte Zeitpunkt wird auch **im Tick** gelesen: ein manueller Scan aus Der gespeicherte Zeitpunkt wird auch **im Tick** gelesen: ein manueller Scan aus
der Oberflaeche schreibt ihn, weiss aber nichts von der Variablen im Scheduler. der Oberfläche schreibt ihn, weiß aber nichts von der Variablen im Scheduler.
Ohne den Abgleich koennte der Scheduler direkt hinter einem manuellen Lauf noch Ohne den Abgleich könnte der Scheduler direkt hinter einem manuellen Lauf noch
einmal losziehen. einmal losziehen.
## Parallelbetrieb - alles verdoppelt ## Parallelbetrieb - alles verdoppelt
@@ -107,7 +107,7 @@ flowchart LR
N4["SCANNER_ENCRYPTION_KEY"] --- N1 N4["SCANNER_ENCRYPTION_KEY"] --- N1
end end
B["Projektboersen"] B["Projektbörsen"]
O["OpenAI"] O["OpenAI"]
A1 --> B A1 --> B
N1 --> B N1 --> B
@@ -122,16 +122,16 @@ flowchart LR
| | alte App | CRM | | | alte App | CRM |
| --- | --- | --- | | --- | --- | --- |
| Datenbank | `project_scanner` | `perinco_scanner` | | Datenbank | `project_scanner` | `perinco_scanner` |
| Schluessel | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` | | Schlüssel | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` |
| CV-Dateien | eigener Ordner | eigener Ordner | | CV-Dateien | eigener Ordner | eigener Ordner |
| Aufraeumen | 03:00 | 03:30 | | Aufräumen | 03:00 | 03:30 |
| Anmeldung | HTTP Basic | CRM-Sitzung (JWT) | | Anmeldung | HTTP Basic | CRM-Sitzung (JWT) |
**Der Preis:** Beide bewerten dieselben Projekte unabhaengig voneinander, die **Der Preis:** Beide bewerten dieselben Projekte unabhängig voneinander, die
OpenAI-Kosten fallen also doppelt an. Wer das senken will, stellt im CRM ein OpenAI-Kosten fallen also doppelt an. Wer das senken will, stellt im CRM ein
groesseres Suchintervall ein - die Einstellung gilt nur fuer diese Instanz. größeres Suchintervall ein - die Einstellung gilt nur für diese Instanz.
## Uebernahme aus der alten Datenbank ## Übernahme aus der alten Datenbank
`server/src/scanner/importLegacy.js`, aufrufbar als `npm run scanner:import`: `server/src/scanner/importLegacy.js`, aufrufbar als `npm run scanner:import`:
@@ -140,9 +140,9 @@ flowchart TD
S["Start"] --> C{"Zieltabelle agents leer?"} S["Start"] --> C{"Zieltabelle agents leer?"}
C -->|nein, ohne --force| AB["Abbruch"] C -->|nein, ohne --force| AB["Abbruch"]
C -->|ja oder --force| E["Einstellungen kopieren<br/>inkl. OpenAI-Key"] C -->|ja oder --force| E["Einstellungen kopieren<br/>inkl. OpenAI-Key"]
E --> P["Portale ueber den Namen zuordnen:<br/>Benutzername, Session-Cookies, Status"] E --> P["Portale über den Namen zuordnen:<br/>Benutzername, Session-Cookies, Status"]
P --> PW{"LEGACY_ENCRYPTION_KEY gesetzt?"} P --> PW{"LEGACY_ENCRYPTION_KEY gesetzt?"}
PW -->|ja| PW2["Passwort entschluesseln<br/>und neu verschluesseln"] PW -->|ja| PW2["Passwort entschlüsseln<br/>und neu verschlüsseln"]
PW -->|nein| PW3["Passwort auslassen"] PW -->|nein| PW3["Passwort auslassen"]
PW2 --> A["Agenten anlegen, Ids behalten<br/>CV-PDF kopieren"] PW2 --> A["Agenten anlegen, Ids behalten<br/>CV-PDF kopieren"]
PW3 --> A PW3 --> A
@@ -152,33 +152,33 @@ flowchart TD
M2 --> F M2 --> F
``` ```
Die Ids bleiben erhalten, damit uebernommene Treffer weiter auf die richtigen Die Ids bleiben erhalten, damit übernommene Treffer weiter auf die richtigen
Agenten und Projekte zeigen. Deshalb bricht das Skript bei bereits vorhandenen Agenten und Projekte zeigen. Deshalb bricht das Skript bei bereits vorhandenen
Agenten ab. Agenten ab.
> **Beim Rollout am 06.08.2026 festgestellt:** Die Portal-Passwoerter der alten > **Beim Rollout am 06.08.2026 festgestellt:** Die Portal-Passwörter der alten
> Datenbank lassen sich mit dem dortigen `ENCRYPTION_KEY` nicht mehr > Datenbank lassen sich mit dem dortigen `ENCRYPTION_KEY` nicht mehr
> entschluesseln - der Schluessel wurde nach dem Speichern getauscht. Die alte App > entschlüsseln - der Schlüssel wurde nach dem Speichern getauscht. Die alte App
> kann sie damit ebenso wenig lesen, es ging also nichts verloren. Die > kann sie damit ebenso wenig lesen, es ging also nichts verloren. Die
> Session-Cookies liegen im Klartext und wurden uebernommen; damit scrapt die > Session-Cookies liegen im Klartext und wurden übernommen; damit scrapt die
> neue Instanz sofort. > neue Instanz sofort.
## Oberflaeche ## Oberfläche
| Reiter | Datei | Inhalt | | Reiter | Datei | Inhalt |
| --- | --- | --- | | --- | --- | --- |
| Dashboard | `ScannerDashboard.tsx` | Kennzahlen, Filter, Trefferliste, *Jetzt scannen*, Ton bei neuen Treffern | | Dashboard | `ScannerDashboard.tsx` | Kennzahlen, Filter, Trefferliste, *Jetzt scannen*, Ton bei neuen Treffern |
| Unpassend | `ScannerRejected.tsx` | Treffer mit Status `rejected` | | Unpassend | `ScannerRejected.tsx` | Treffer mit Status `rejected` |
| Suchagenten | `ScannerAgents.tsx` | Beraterprofile, CV-Upload | | Suchagenten | `ScannerAgents.tsx` | Beraterprofile, CV-Upload |
| Boersen-Konten | `ScannerPortals.tsx` | Verbindungsstatus, Zugangsdaten, Cookie-Import | | Börsen-Konten | `ScannerPortals.tsx` | Verbindungsstatus, Zugangsdaten, Cookie-Import |
| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, OpenAI, Ton, E-Mail | | Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, OpenAI, Ton, E-Mail |
**Wichtig zum Ton:** Er haengt an der Dashboard-Komponente. Wer auf einen anderen **Wichtig zum Ton:** Er hängt an der Dashboard-Komponente. Wer auf einen anderen
Menuepunkt wechselt, hoert nichts - die Komponente wird ausgebaut und die Menüpunkt wechselt, hört nichts - die Komponente wird ausgebaut und die
30-Sekunden-Abfrage endet. In einem anderen Browsertab bleibt der Ton dagegen 30-Sekunden-Abfrage endet. In einem anderen Browsertab bleibt der Ton dagegen
aktiv, nur langsamer (Chrome bremst Timer versteckter Tabs auf etwa einmal pro 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). Browserunabhaengig meldet nur die E-Mail. (Autoplay-Regel). Browserunabhängig meldet nur die E-Mail.
## Endpunkte ## Endpunkte
@@ -187,15 +187,15 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`:
| Methode | Pfad | Zweck | | Methode | Pfad | Zweck |
| --- | --- | --- | | --- | --- | --- |
| GET | `/agents` | Liste ohne `cvText` (Nutzlast) | | GET | `/agents` | Liste ohne `cvText` (Nutzlast) |
| POST/PUT | `/agents[/:id]` | anlegen/aendern, `multipart` mit CV | | POST/PUT | `/agents[/:id]` | anlegen/ändern, `multipart` mit CV |
| DELETE | `/agents/:id` | Agent samt CV-Datei | | DELETE | `/agents/:id` | Agent samt CV-Datei |
| GET | `/agents/:id/cv` | PDF als Stream | | GET | `/agents/:id/cv` | PDF als Stream |
| GET | `/portals` | Status ohne Geheimnisse | | GET | `/portals` | Status ohne Geheimnisse |
| POST | `/portals/credentials` | Zugangsdaten, AES-verschluesselt | | POST | `/portals/credentials` | Zugangsdaten, AES-verschlüsselt |
| POST | `/portals/sync-session` | Cookie-Import, normalisiert fuer Playwright | | POST | `/portals/sync-session` | Cookie-Import, normalisiert für Playwright |
| POST | `/portals/test-connection/:id` | Mini-Scrape bzw. Session-Pruefung | | POST | `/portals/test-connection/:id` | Mini-Scrape bzw. Session-Prüfung |
| GET | `/matches` | Filter: `status`, `agentId`, `minPercentage`, `limit` | | GET | `/matches` | Filter: `status`, `agentId`, `minPercentage`, `limit` |
| PUT/DELETE | `/matches/:id` | Status setzen, loeschen | | PUT/DELETE | `/matches/:id` | Status setzen, löschen |
| GET/POST | `/settings` | Geheimnisse maskiert | | GET/POST | `/settings` | Geheimnisse maskiert |
| POST | `/scan` | Lauf starten, `409` wenn schon einer laeuft | | POST | `/scan` | Lauf starten, `409` wenn schon einer läuft |
| GET | `/status` | laeuft ein Lauf, Scheduler an, Datenbankname | | GET | `/status` | läuft ein Lauf, Scheduler an, Datenbankname |
+12 -12
View File
@@ -1,8 +1,8 @@
# 04 Diktat (Whisper im Browser) # 04 Diktat (Whisper im Browser)
Ueberall, wo laengerer Text entsteht - Projektbeschreibung, Interaktionsnotiz, Überall, wo längerer Text entsteht - Projektbeschreibung, Interaktionsnotiz,
naechster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung nächster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung
laeuft **vollstaendig im Browser des Benutzers**. läuft **vollständig im Browser des Benutzers**.
Das Backend ist daran nicht beteiligt und sieht keine Audiodaten: kein API-Key, Das Backend ist daran nicht beteiligt und sieht keine Audiodaten: kein API-Key,
keine Kosten pro Minute, kein Datenabfluss. Aufnahmen werden nirgends gespeichert. keine Kosten pro Minute, kein Datenabfluss. Aufnahmen werden nirgends gespeichert.
@@ -23,7 +23,7 @@ flowchart LR
``` ```
Die Erkennung liegt im Web Worker (`src/lib/whisper.worker.ts`), damit die Die Erkennung liegt im Web Worker (`src/lib/whisper.worker.ts`), damit die
Oberflaeche waehrenddessen nicht einfriert. Die ONNX-Laufzeit wird mit Oberfläche währenddessen nicht einfriert. Die ONNX-Laufzeit wird mit
ausgeliefert (Vite bundelt die `.wasm`), nur die Modelldateien kommen beim ersten ausgeliefert (Vite bundelt die `.wasm`), nur die Modelldateien kommen beim ersten
Laden vom CDN und bleiben danach im Cache-Speicher der Seite. Laden vom CDN und bleiben danach im Cache-Speicher der Seite.
@@ -36,7 +36,7 @@ stateDiagram-v2
loading --> ready : im Worker initialisiert loading --> ready : im Worker initialisiert
loading --> error : Abbruch, Haenger, kein Speicher loading --> error : Abbruch, Haenger, kein Speicher
error --> loading : erneut versuchen error --> loading : erneut versuchen
ready --> idle : "Sprachmodell loeschen" ready --> idle : "Sprachmodell löschen"
ready --> ready : Diktat benutzen ready --> ready : Diktat benutzen
note right of loading note right of loading
@@ -47,14 +47,14 @@ stateDiagram-v2
Beim Anmelden wird das Modell automatisch bereitgestellt, **wenn** es schon in Beim Anmelden wird das Modell automatisch bereitgestellt, **wenn** es schon in
diesem Browser liegt. Ein Download von 759 MB startet nie von allein, und der diesem Browser liegt. Ein Download von 759 MB startet nie von allein, und der
Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell loescht, Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell löscht,
bekommt es nicht sofort wieder heruntergeladen. bekommt es nicht sofort wieder heruntergeladen.
## Wo es sitzt ## Wo es sitzt
Reiter **Whisper** in den Einstellungen - fuer **jede Rolle** sichtbar, denn jeder Reiter **Whisper** in den Einstellungen - für **jede Rolle** sichtbar, denn jeder
richtet sein Diktat auf seinem eigenen Geraet ein. Genau deshalb sind die richtet sein Diktat auf seinem eigenen Gerät ein. Genau deshalb sind die
Einstellungen ueberhaupt fuer alle Rollen zugaenglich; welche Reiter erscheinen, Einstellungen überhaupt für alle Rollen zugänglich; welche Reiter erscheinen,
entscheidet die Rolle: entscheidet die Rolle:
```mermaid ```mermaid
@@ -76,10 +76,10 @@ Modell, Genauigkeit, Rechenweg und Sprache stehen als Konstanten in
Deutsch. Jede andere Kombination war in den Messungen entweder schlechter oder Deutsch. Jede andere Kombination war in den Messungen entweder schlechter oder
kaputt - bei `small` in fp16 auf WebGPU reagiert der ganze Browser-Tab nicht mehr, kaputt - bei `small` in fp16 auf WebGPU reagiert der ganze Browser-Tab nicht mehr,
und **kein Timeout in der Seite kann das abfangen**, weil das Skript selbst nicht und **kein Timeout in der Seite kann das abfangen**, weil das Skript selbst nicht
mehr laeuft. mehr läuft.
Eine Auswahl haette also nur Gelegenheit geboten, etwas falsch zu machen. Die Eine Auswahl hätte also nur Gelegenheit geboten, etwas falsch zu machen. Die
Messwerte stehen im [README](../README.md#warum-small-und-q4---die-messungen). Messwerte stehen im [README](../README.md#warum-small-und-q4---die-messungen).
Als Netz bleibt eine Frist pro Erkennung (15x Aufnahmedauer, mindestens 45 s, Als Netz bleibt eine Frist pro Erkennung (15x Aufnahmedauer, mindestens 45 s,
hoechstens 5 min) und eine Haenger-Erkennung beim Laden. höchstens 5 min) und eine Hänger-Erkennung beim Laden.
+24 -24
View File
@@ -1,10 +1,10 @@
# 05 Frontend # 05 Frontend
Eine einzige React-Anwendung ohne Router: `App.tsx` haelt die aktuelle Ansicht in Eine einzige React-Anwendung ohne Router: `App.tsx` hält die aktuelle Ansicht in
einem Zustand und rendert sie. Das genuegt, weil die App hinter einer Anmeldung einem Zustand und rendert sie. Das genügt, weil die App hinter einer Anmeldung
sitzt und keine tiefen Links braucht. sitzt und keine tiefen Links braucht.
## Menue und Ansichten ## Menü und Ansichten
```mermaid ```mermaid
flowchart TD flowchart TD
@@ -13,15 +13,15 @@ flowchart TD
K --> C["Kalender"] K --> C["Kalender"]
K --> L["Kontakte"] K --> L["Kontakte"]
L --> L1["Alle Kontakte"] L --> L1["Alle Kontakte"]
L --> L2["Aus Mails<br/>Vorbereich, Zaehler im Menue"] L --> L2["Aus Mails<br/>Vorbereich, Zähler im Menü"]
K --> M["Mails"] K --> M["Mails"]
M --> M1["Postfach"] M --> M1["Postfach"]
M --> M2["Kontakt-Vorschlaege"] M --> M2["Kontakt-Vorschläge"]
K --> SC["Scanner"] K --> SC["Scanner"]
SC --> SC1["Dashboard"] SC --> SC1["Dashboard"]
SC --> SC2["Unpassend"] SC --> SC2["Unpassend"]
SC --> SC3["Suchagenten"] SC --> SC3["Suchagenten"]
SC --> SC4["Boersen-Konten"] SC --> SC4["Börsen-Konten"]
SB --> E["Einstellungen"] SB --> E["Einstellungen"]
E --> E1["Nutzerverwaltung (admin)"] E --> E1["Nutzerverwaltung (admin)"]
E --> E2["Mein Konto"] E --> E2["Mein Konto"]
@@ -30,16 +30,16 @@ flowchart TD
E --> E5["Whisper"] E --> E5["Whisper"]
``` ```
*Postfach* und *Kontakt-Vorschlaege* sind Reiter **innerhalb** der Ansicht *Postfach* und *Kontakt-Vorschläge* sind Reiter **innerhalb** der Ansicht
*Mails*, keine Menuepunkte - sie gehoeren zusammen und wechseln oft hin und her. *Mails*, keine Menüpunkte - sie gehören zusammen und wechseln oft hin und her.
Die Menuepunkte *Kontakte* und *Scanner* klappen ihre Unterpunkte auf; der Die Menüpunkte *Kontakte* und *Scanner* klappen ihre Unterpunkte auf; der
Kopfeintrag ist dabei selbst eine Ansicht (Kontaktliste bzw. Scanner-Dashboard). Kopfeintrag ist dabei selbst eine Ansicht (Kontaktliste bzw. Scanner-Dashboard).
Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole, die Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole, die
Unterpunkte kommen mit den Beschriftungen zurueck. Beide Gruppen entstehen aus Unterpunkte kommen mit den Beschriftungen zurück. Beide Gruppen entstehen aus
derselben Struktur `MenuGroup` in `Sidebar.tsx`. derselben Struktur `MenuGroup` in `Sidebar.tsx`.
*Aus Mails* traegt einen Zaehler mit der Anzahl der offenen Funde - er kommt aus *Aus Mails* trägt einen Zähler mit der Anzahl der offenen Funde - er kommt aus
`App.tsx`, das die Kontakte ohnehin geladen hat. `App.tsx`, das die Kontakte ohnehin geladen hat.
## Zustand und Datenfluss ## Zustand und Datenfluss
@@ -64,34 +64,34 @@ flowchart TB
DATA --> V2["Kalender"] DATA --> V2["Kalender"]
DATA --> V3["Kontakte<br/>mail_pool = false"] DATA --> V3["Kontakte<br/>mail_pool = false"]
DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"] DATA --> V5["Aus Mails<br/>mail_pool = true + Kontakte zum Abgleich"]
DATA --> V4["Mails<br/>leads + opportunities fuer den Zuordnungsdialog"] DATA --> V4["Mails<br/>leads + opportunities für den Zuordnungsdialog"]
NAV --> DF["DictationField<br/>springt nach Whisper"] NAV --> DF["DictationField<br/>springt nach Whisper"]
NAV --> ML["Mails<br/>springt zum Kontakt einer Zuordnung"] NAV --> ML["Mails<br/>springt zum Kontakt einer Zuordnung"]
SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"] SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"]
MV["Mailansicht"] -->|"laedt selbst"| MAPI["/api/mail"] MV["Mailansicht"] -->|"lädt selbst"| MAPI["/api/mail"]
``` ```
Die CRM-Daten werden **einmal zentral** geladen und als Props durchgereicht; die Die CRM-Daten werden **einmal zentral** geladen und als Props durchgereicht; die
Scanner- und Mailansichten laden ihre eigenen Daten selbst - der Scanner, weil er Scanner- und Mailansichten laden ihre eigenen Daten selbst - der Scanner, weil er
eine andere Datenbank und eine Auffrischung im 30-Sekunden-Takt hat; die Mails, eine andere Datenbank und eine Auffrischung im 30-Sekunden-Takt hat; die Mails,
weil sie blaettern, suchen und filtern und dabei nicht die ganze Liste im Speicher weil sie blättern, suchen und filtern und dabei nicht die ganze Liste im Speicher
halten sollen. Kontakte und Opportunities bekommt die Mailansicht dagegen als halten sollen. Kontakte und Opportunities bekommt die Mailansicht dagegen als
Props: der Zuordnungsdialog braucht sie beide zum Vorschlagen. Props: der Zuordnungsdialog braucht sie beide zum Vorschlagen.
`/api/leads` liefert beide Bestaende in einem Aufruf; `App.tsx` teilt sie in `/api/leads` liefert beide Bestände in einem Aufruf; `App.tsx` teilt sie in
`leads` (Kontakte) und `mailPoolLeads` (Vorbereich „Aus Mails") auf. Alle `leads` (Kontakte) und `mailPoolLeads` (Vorbereich „Aus Mails") auf. Alle
anderen Ansichten bekommen ausschliesslich `leads` - ein Fund aus einer Mail soll anderen Ansichten bekommen ausschließlich `leads` - ein Fund aus einer Mail soll
in keiner Auswahlliste auftauchen. Die Ansicht *Aus Mails* braucht beides: sie in keiner Auswahlliste auftauchen. Die Ansicht *Aus Mails* braucht beides: sie
vergleicht jeden Fund gegen den Kontaktbestand und markiert Dubletten. vergleicht jeden Fund gegen den Kontaktbestand und markiert Dubletten.
`goTo('Kontakte', { leadId })` ist der Sprung aus einer Mailzuordnung zum `goTo('Kontakte', { leadId })` ist der Sprung aus einer Mailzuordnung zum
Kontakt. `App.tsx` haelt die Id in `focusLeadId`, die Kontaktansicht oeffnet den Kontakt. `App.tsx` hält die Id in `focusLeadId`, die Kontaktansicht öffnet den
Kontakt und meldet mit `onFocusHandled()` zurueck - der Effekt haengt an `leads` Kontakt und meldet mit `onFocusHandled()` zurück - der Effekt hängt an `leads`
und wartet damit auf einen gerade erst angelegten Kontakt. und wartet damit auf einen gerade erst angelegten Kontakt.
`reload={() => loadData(true)}` laedt still nach - ohne Ladeanzeige, damit ein `reload={() => loadData(true)}` lädt still nach - ohne Ladeanzeige, damit ein
Speichern im Dialog nicht die ganze Liste flackern laesst. Speichern im Dialog nicht die ganze Liste flackern lässt.
## Komponentenkarte ## Komponentenkarte
@@ -127,8 +127,8 @@ src/
## Dark Mode ohne `dark:`-Varianten ## Dark Mode ohne `dark:`-Varianten
Die Klasse `dark` auf `<html>` tauscht in `src/index.css` nur die Werte der Die Klasse `dark` auf `<html>` tauscht in `src/index.css` nur die Werte der
Variablen `canvas`, `surface`, `ink-*`, `brand-*` und der getoenten Statusfarben. Variablen `canvas`, `surface`, `ink-*`, `brand-*` und der getönten Statusfarben.
Die Komponenten benutzen durchgaengig diese Skalen und folgen damit automatisch - Die Komponenten benutzen durchgängig diese Skalen und folgen damit automatisch -
kein `dark:` im Markup. kein `dark:` im Markup.
```mermaid ```mermaid
@@ -143,9 +143,9 @@ flowchart LR
Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
blitzt beim Laden die helle Variante auf. Drei Werte bleiben in beiden Modi blitzt beim Laden die helle Variante auf. Drei Werte bleiben in beiden Modi
gleich: `ink-950` (liegt nur unter Dialog-Overlays), `text-white` (steht immer auf gleich: `ink-950` (liegt nur unter Dialog-Overlays), `text-white` (steht immer auf
farbigen Flaechen) und `brand-500` (das CI-Gruen der Buttons). farbigen Flächen) und `brand-500` (das CI-Grün der Buttons).
Eine bewusste Ausnahme: der **Mailinhalt** bleibt auch im Dunkelmodus auf weissem Eine bewusste Ausnahme: der **Mailinhalt** bleibt auch im Dunkelmodus auf weissem
Grund. Fremdes Mail-HTML bringt seine eigenen Farben mit; ein dunkler Hintergrund Grund. Fremdes Mail-HTML bringt seine eigenen Farben mit; ein dunkler Hintergrund
darunter macht schwarze Schrift auf schwarzer Flaeche. Der Rahmen liest sich damit darunter macht schwarze Schrift auf schwarzer Fläche. Der Rahmen liest sich damit
wie ein Blatt Papier im dunklen Fenster - gewollt. wie ein Blatt Papier im dunklen Fenster - gewollt.
+22 -22
View File
@@ -26,7 +26,7 @@ powershell -File deploy/deploy.ps1 -SkipBuild # ohne neuen Build
Das Passwort kommt aus `$env:PERINCO_CRM_SSH_PASSWORD` oder per `-Password`. Das Passwort kommt aus `$env:PERINCO_CRM_SSH_PASSWORD` oder per `-Password`.
`deploy.ps1` laedt die systemd-Unit **nicht** mit hoch. Nach Aenderungen an `deploy.ps1` lädt die systemd-Unit **nicht** mit hoch. Nach Änderungen an
`deploy/perinco-crm-backend.service` von Hand kopieren und `daemon-reload`. `deploy/perinco-crm-backend.service` von Hand kopieren und `daemon-reload`.
## Dienste auf dem Server ## Dienste auf dem Server
@@ -34,14 +34,14 @@ Das Passwort kommt aus `$env:PERINCO_CRM_SSH_PASSWORD` oder per `-Password`.
| Unit | Verzeichnis | Port | Anmerkung | | Unit | Verzeichnis | Port | Anmerkung |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `perinco-crm-backend` | `/var/www/perinco_CRM/server` | 5006 | `MemoryMax=1536M` | | `perinco-crm-backend` | `/var/www/perinco_CRM/server` | 5006 | `MemoryMax=1536M` |
| `project-scanner-backend` | `/var/www/Projekt Scanner/backend` | 5005 | alte App, laeuft bis zur Ablosung | | `project-scanner-backend` | `/var/www/Projekt Scanner/backend` | 5005 | alte App, läuft bis zur Ablosung |
Der Verzeichnisname der alten App enthaelt ein **Leerzeichen**. In Skripten und in Der Verzeichnisname der alten App enthält ein **Leerzeichen**. In Skripten und in
der `.env` immer in Anfuehrungszeichen setzen - systemd liest `.env` als der `.env` immer in Anführungszeichen setzen - systemd liest `.env` als
`EnvironmentFile` und trennt sonst am Leerzeichen. `EnvironmentFile` und trennt sonst am Leerzeichen.
`MemoryMax=1536M` statt der urspruenglichen 512M: ein Scan-Lauf startet Chromium `MemoryMax=1536M` statt der ursprünglichen 512M: ein Scan-Lauf startet Chromium
als Kindprozess in derselben cgroup. Mit 512M wuerde der OOM-Killer das Backend als Kindprozess in derselben cgroup. Mit 512M würde der OOM-Killer das Backend
mitten im Scrapen beenden. mitten im Scrapen beenden.
## Umgebungsvariablen ## Umgebungsvariablen
@@ -50,21 +50,21 @@ mitten im Scrapen beenden.
| Variable | Bedeutung | | Variable | Bedeutung |
| --- | --- | | --- | --- |
| `PORT`, `HOST` | `5006`, `127.0.0.1` - von aussen nur ueber Apache | | `PORT`, `HOST` | `5006`, `127.0.0.1` - von außen nur über Apache |
| `DB_*` | Verbindung zu `perinco_CRM` | | `DB_*` | Verbindung zu `perinco_CRM` |
| `JWT_SECRET`, `JWT_EXPIRES_IN` | Signatur der Sitzungen, Standard 12 h | | `JWT_SECRET`, `JWT_EXPIRES_IN` | Signatur der Sitzungen, Standard 12 h |
| `ADMIN_*` | wird beim ersten Start angelegt, nur wenn `crm_users` leer ist | | `ADMIN_*` | wird beim ersten Start angelegt, nur wenn `crm_users` leer ist |
| `SCANNER_DB_NAME` | eigene Scanner-Datenbank, Standard `perinco_scanner` | | `SCANNER_DB_NAME` | eigene Scanner-Datenbank, Standard `perinco_scanner` |
| `SCANNER_ENCRYPTION_KEY` | AES-Schluessel fuer Portal-Passwoerter | | `SCANNER_ENCRYPTION_KEY` | AES-Schlüssel für Portal-Passwörter |
| `SCANNER_UPLOAD_DIR` | CV-Verzeichnis, leer = `<server>/uploads` | | `SCANNER_UPLOAD_DIR` | CV-Verzeichnis, leer = `<server>/uploads` |
| `SCANNER_SCHEDULER` | Hintergrund-Scraper, Standard an, nur `false` schaltet ab | | `SCANNER_SCHEDULER` | Hintergrund-Scraper, Standard an, nur `false` schaltet ab |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den ersten Lauf, Standard 30 | | `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz für den ersten Lauf, Standard 30 |
| `LEGACY_*` | Quelle fuer `npm run scanner:import` | | `LEGACY_*` | Quelle für `npm run scanner:import` |
| `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | App-only-Zugriff auf Microsoft Graph | | `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | App-only-Zugriff auf Microsoft Graph |
| `MS_MAILBOX` | abzugleichendes Postfach, Standard `crm@perinco.de` | | `MS_MAILBOX` | abzugleichendes Postfach, Standard `crm@perinco.de` |
| `MS_MAIL_FOLDER` | Ordner, Standard `inbox` | | `MS_MAIL_FOLDER` | Ordner, Standard `inbox` |
| `MAIL_SYNC` | Postfachabgleich, Standard an, nur `false` schaltet ab | | `MAIL_SYNC` | Postfachabgleich, Standard an, nur `false` schaltet ab |
| `MAIL_MAX_PAGES`, `MAIL_MAX_BODY_CHARS` | Bremsen fuer Erstabgleich und Textgroesse | | `MAIL_MAX_PAGES`, `MAIL_MAX_BODY_CHARS` | Bremsen für Erstabgleich und Textgröße |
Die Scanner-Datenbank wird beim Start automatisch angelegt Die Scanner-Datenbank wird beim Start automatisch angelegt
(`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale (`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale
@@ -75,16 +75,16 @@ Fehlen die `MS_*`-Werte, startet das Backend normal und protokolliert
*Postfach* nennen die fehlende Variable beim Namen. Einrichtung in Entra und *Postfach* nennen die fehlende Variable beim Namen. Einrichtung in Entra und
Exchange Online: [11 Postfachanbindung](11-postfach.md). Exchange Online: [11 Postfachanbindung](11-postfach.md).
> Die `MS_*`-Werte werden **beim Start** gelesen. Nach einer Aenderung an der > Die `MS_*`-Werte werden **beim Start** gelesen. Nach einer Änderung an der
> `.env` muss der Dienst neu starten, ein Speichern in der Oberflaeche genuegt > `.env` muss der Dienst neu starten, ein Speichern in der Oberfläche genügt
> nicht - dort liegen nur Intervall, Schalter und Zeitraum (`crm_settings`). > nicht - dort liegen nur Intervall, Schalter und Zeitraum (`crm_settings`).
## Playwright ## Playwright
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die alte App. `playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die alte App.
Deren Chromium-Build (`chromium-1223`) liegt in `/root/.cache/ms-playwright`, also Deren Chromium-Build (`chromium-1223`) liegt in `/root/.cache/ms-playwright`, also
faellt kein zweiter Browser-Download an. Ein `^` in der Version wuerde das fällt kein zweiter Browser-Download an. Ein `^` in der Version würde das
aufgeben; die Begruendung steht als `comments`-Feld in `server/package.json`. aufgeben; die Begründung steht als `comments`-Feld in `server/package.json`.
Fehlt der Browser, funktioniert alles ausser Scan-Lauf und Verbindungstest: Fehlt der Browser, funktioniert alles ausser Scan-Lauf und Verbindungstest:
@@ -103,16 +103,16 @@ systemctl restart perinco-crm-backend
``` ```
> **Reihenfolge:** Der Import schreibt `search_interval` aus der alten App in die > **Reihenfolge:** Der Import schreibt `search_interval` aus der alten App in die
> Einstellungen. Der laufende Scheduler merkt die Aenderung binnen einer Minute > Einstellungen. Der laufende Scheduler merkt die Änderung binnen einer Minute
> und plant sich neu - beim Rollout hat er direkt nach dem Import einen Scan > und plant sich neu - beim Rollout hat er direkt nach dem Import einen Scan
> gestartet, den der folgende Restart mitten im Scrapen beendet hat. Harmlos, aber > gestartet, den der folgende Restart mitten im Scrapen beendet hat. Harmlos, aber
> vermeidbar: erst neu starten, dann importieren. > vermeidbar: erst neu starten, dann importieren.
## Stoerungssuche ## Störungssuche
| Symptom | Blick darauf | | Symptom | Blick darauf |
| --- | --- | | --- | --- |
| Anmeldung schlaegt fehl | `journalctl -u perinco-crm-backend -n 50`, `JWT_SECRET` gesetzt? | | Anmeldung schlägt fehl | `journalctl -u perinco-crm-backend -n 50`, `JWT_SECRET` gesetzt? |
| Scanner-Reiter leer, 500er | `[Scanner] Datenbank nicht erreichbar` im Journal | | Scanner-Reiter leer, 500er | `[Scanner] Datenbank nicht erreichbar` im Journal |
| Kein Treffer trotz Scan | `scanning_active`, aktive Agenten mit CV, OpenAI-Key gesetzt | | Kein Treffer trotz Scan | `scanning_active`, aktive Agenten mit CV, OpenAI-Key gesetzt |
| Portal `expired` | Cookies neu importieren (Cookie-Editor) | | Portal `expired` | Cookies neu importieren (Cookie-Editor) |
@@ -120,9 +120,9 @@ systemctl restart perinco-crm-backend
| Backend stirbt beim Scrapen | `MemoryMax` zu klein, `systemctl show ... -p MemoryCurrent` | | Backend stirbt beim Scrapen | `MemoryMax` zu klein, `systemctl show ... -p MemoryCurrent` |
| Ton bleibt aus | Schwellenwert, Dashboard-Ansicht offen, einmal auf der Seite geklickt? | | Ton bleibt aus | Schwellenwert, Dashboard-Ansicht offen, einmal auf der Seite geklickt? |
| Keine Mails im Postfachreiter | `journalctl -u perinco-crm-backend \| grep '\[Mail\]'`, dann Verbindungstest in den Einstellungen | | Keine Mails im Postfachreiter | `journalctl -u perinco-crm-backend \| grep '\[Mail\]'`, dann Verbindungstest in den Einstellungen |
| Mail-Abgleich meldet `ErrorAccessDenied` | Administratorzustimmung fuer `Mail.Read` fehlt, oder die ApplicationAccessPolicy erlaubt das Postfach nicht | | Mail-Abgleich meldet `ErrorAccessDenied` | Administratorzustimmung für `Mail.Read` fehlt, oder die ApplicationAccessPolicy erlaubt das Postfach nicht |
Nuetzliche Abfragen: Nützliche Abfragen:
```bash ```bash
systemctl status perinco-crm-backend systemctl status perinco-crm-backend
@@ -151,6 +151,6 @@ mysqldump -u"$DB_USER" -p"$DB_PASSWORD" "$SCANNER_DB_NAME" > scanner_$(date +%F)
tar czf uploads_$(date +%F).tar.gz -C /var/www/perinco_CRM/server uploads tar czf uploads_$(date +%F).tar.gz -C /var/www/perinco_CRM/server uploads
``` ```
Die `.env` gehoert **nicht** ins Repository, aber in die Sicherung - ohne Die `.env` gehört **nicht** ins Repository, aber in die Sicherung - ohne
`SCANNER_ENCRYPTION_KEY` sind die Portal-Passwoerter verloren. Genau das ist der `SCANNER_ENCRYPTION_KEY` sind die Portal-Passwörter verloren. Genau das ist der
alten App passiert. alten App passiert.
+140 -136
View File
@@ -1,30 +1,30 @@
# 07 Anleitung fuer Benutzer # 07 Anleitung für Benutzer
Diese Anleitung setzt keine Vorkenntnisse voraus. Sie beschreibt, was die Diese Anleitung setzt keine Vorkenntnisse voraus. Sie beschreibt, was die
Anwendung tut und wie man sie bedient. Anwendung tut und wie man sie bedient.
## Was die Anwendung ist ## Was die Anwendung ist
Ein CRM fuer die Projektvermittlung. Es beantwortet vier Fragen: Ein CRM für die Projektvermittlung. Es beantwortet vier Fragen:
| Frage | Bereich | | Frage | Bereich |
| --- | --- | | --- | --- |
| Welche Geschaefte sind in Arbeit und was sind sie wert? | **Opportunities** | | Welche Geschäfte sind in Arbeit und was sind sie wert? | **Opportunities** |
| Wer ist wann wo, und wer ist beteiligt? | **Kalender** | | Wer ist wann wo, und wer ist beteiligt? | **Kalender** |
| Wen kennen wir, und wer kennt wen? | **Kontakte** | | Wen kennen wir, und wer kennt wen? | **Kontakte** |
| Welche Projekte auf den Boersen passen zu unseren Beratern? | **Scanner** | | Welche Projekte auf den Börsen passen zu unseren Beratern? | **Scanner** |
## Anmelden ## Anmelden
`https://pcrm.perinco.de`. Anmelden geht mit **Benutzername oder E-Mail** plus `https://pcrm.perinco.de`. Anmelden geht mit **Benutzername oder E-Mail** plus
Passwort. Die Sitzung haelt 12 Stunden, danach fragt die Anwendung erneut. Passwort. Die Sitzung hält 12 Stunden, danach fragt die Anwendung erneut.
Sagt die Meldung „Benutzername oder Passwort ist falsch", bleibt bewusst offen, Sagt die Meldung „Benutzername oder Passwort ist falsch", bleibt bewusst offen,
welches von beidem nicht stimmte - so kann ueber die Anmeldemaske niemand welches von beidem nicht stimmte - so kann über die Anmeldemaske niemand
Benutzernamen durchprobieren. Benutzernamen durchprobieren.
Es gibt zwei Rollen: **Administrator** sieht zusaetzlich die Nutzerverwaltung und Es gibt zwei Rollen: **Administrator** sieht zusätzlich die Nutzerverwaltung und
die Scanner-Einstellungen. Alles andere ist fuer beide Rollen gleich. die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
## Sich zurechtfinden ## Sich zurechtfinden
@@ -33,13 +33,15 @@ die Scanner-Einstellungen. Alles andere ist fuer beide Rollen gleich.
│ Kategorien │ Titel der Ansicht [Hell/Dunkel] │ │ Kategorien │ Titel der Ansicht [Hell/Dunkel] │
│ Opportunities├──────────────────────────────────────────┤ │ Opportunities├──────────────────────────────────────────┤
│ Kalender │ │ │ Kalender │ │
│ Kontakte │ Inhalt │ │ Kontakte ▾ │ Inhalt │
│ Alle Kont. │ │
│ Aus Mails 5│ │
│ Mails │ │ │ Mails │ │
│ Scanner ▾ │ │ │ Scanner ▾ │ │
│ Dashboard │ │ │ Dashboard │ │
│ Unpassend │ │ │ Unpassend │ │
│ Suchagenten│ │ │ Suchagenten│ │
│ Boersen-… │ │ │ Börsen-… │ │
├──────────────┤ │ ├──────────────┤ │
│ Einstellungen│ │ │ Einstellungen│ │
├──────────────┤ │ ├──────────────┤ │
@@ -47,44 +49,46 @@ die Scanner-Einstellungen. Alles andere ist fuer beide Rollen gleich.
└──────────────┴───────────────────────────────────────────┘ └──────────────┴───────────────────────────────────────────┘
``` ```
* Der Knopf mit den drei Strichen oben links klappt das Menue auf Symbolbreite * Der Knopf mit den drei Strichen oben links klappt das Menü auf Symbolbreite
zusammen - hilfreich auf kleinen Bildschirmen. zusammen - hilfreich auf kleinen Bildschirmen.
* Rechts oben schaltet zwischen hellem und dunklem Design. Ein **Doppelklick** * Rechts oben schaltet zwischen hellem und dunklem Design. Ein **Doppelklick**
auf den Schalter folgt wieder der Systemeinstellung. auf den Schalter folgt wieder der Systemeinstellung.
* Der Menuepunkt **Scanner** klappt beim Anklicken seine Unterpunkte auf. * Die Menüpunkte **Kontakte** und **Scanner** klappen beim Anklicken ihre
Unterpunkte auf. Die Zahl neben *Aus Mails* sagt, wie viele Funde aus dem
Postfach noch auf eine Entscheidung warten.
## Opportunities ## Opportunities
Der Startbereich: eine Opportunity ist ein Geschaeft in Arbeit. Oben rechts Der Startbereich: eine Opportunity ist ein Geschäft in Arbeit. Oben rechts
wechselt man die Darstellung: wechselt man die Darstellung:
| Ansicht | wofuer | | Ansicht | wofür |
| --- | --- | | --- | --- |
| **Kachel** | Ueberblick mit Volumen, Tagessatz, Laufzeit, letzter Interaktion | | **Kachel** | Überblick mit Volumen, Tagessatz, Laufzeit, letzter Interaktion |
| **Liste** | viele Eintraege vergleichen, Spaltenkoepfe sortieren | | **Liste** | viele Einträge vergleichen, Spaltenköpfe sortieren |
| **Pipeline** | Fortschritt durch die Phasen, Karten per Maus verschieben | | **Pipeline** | Fortschritt durch die Phasen, Karten per Maus verschieben |
**Volumen** rechnet die Anwendung selbst: Tagessatz x Personentage. Das **Volumen** rechnet die Anwendung selbst: Tagessatz x Personentage. Das
**gewichtete** Volumen multipliziert das noch mit der Abschlusswahrscheinlichkeit. **gewichtete** Volumen multipliziert das noch mit der Abschlusswahrscheinlichkeit.
Beide Zahlen stehen live im Formular, auf jeder Karte, als Summe ueber der Liste, Beide Zahlen stehen live im Formular, auf jeder Karte, als Summe über der Liste,
je Pipeline-Spalte und in der Kopfzeile. je Pipeline-Spalte und in der Kopfzeile.
Zu jeder Opportunity gehoeren beliebig viele **Ansprechpartner** aus den Zu jeder Opportunity gehören beliebig viele **Ansprechpartner** aus den
Kontakten, jeder mit eigener Rolle („Entscheider", „Fachbereich", …). Kontakten, jeder mit eigener Rolle („Entscheider", „Fachbereich", …).
Aendern und loeschen darf der **Ersteller**, der **Zustaendige** und jeder Ändern und löschen darf der **Ersteller**, der **Zuständige** und jeder
**Administrator**. Wer keines davon ist, bekommt eine Fehlermeldung - das prueft **Administrator**. Wer keines davon ist, bekommt eine Fehlermeldung - das prüft
der Server, nicht nur die Oberflaeche. der Server, nicht nur die Oberfläche.
Auf dem Tablet oder ohne Lust auf Ziehen: die Phase laesst sich auch ueber das Auf dem Tablet oder ohne Lust auf Ziehen: die Phase lässt sich auch über das
Auswahlfeld direkt auf der Karte aendern. Auswahlfeld direkt auf der Karte ändern.
## Kalender ## Kalender
Ein **gemeinsamer** Teamkalender - alle sehen dieselben Termine, jeder darf Ein **gemeinsamer** Teamkalender - alle sehen dieselben Termine, jeder darf
eintragen. eintragen.
* **Monat, Woche, Tag** oben umschalten, `Heute` und die Pfeile blaettern. * **Monat, Woche, Tag** oben umschalten, `Heute` und die Pfeile blättern.
* Klick auf einen Monatstag oder eine Stundenzelle legt sofort einen Termin an. * Klick auf einen Monatstag oder eine Stundenzelle legt sofort einen Termin an.
* Klick auf einen Wochentagskopf springt in die Tagesansicht. * Klick auf einen Wochentagskopf springt in die Tagesansicht.
* **Verschieben per Ziehen**: Termin anfassen und auf einen anderen Tag oder eine * **Verschieben per Ziehen**: Termin anfassen und auf einen anderen Tag oder eine
@@ -92,134 +96,134 @@ eintragen.
stehen; in Woche und Tag rastet der Termin auf **15 Minuten** ein, und eine stehen; in Woche und Tag rastet der Termin auf **15 Minuten** ein, und eine
gestrichelte Linie zeigt vorab die neue Startzeit. Die **Dauer bleibt** immer gestrichelte Linie zeigt vorab die neue Startzeit. Die **Dauer bleibt** immer
gleich, und der Punkt, an dem man den Termin angefasst hat, wandert mit. Ziehen gleich, und der Punkt, an dem man den Termin angefasst hat, wandert mit. Ziehen
darf nur, wer den Termin auch aendern darf. darf nur, wer den Termin auch ändern darf.
* **Farben** gehoeren zum Ersteller. Die eigene Farbe aendert man unter * **Farben** gehören zum Ersteller. Die eigene Farbe ändert man unter
*Einstellungen → Mein Konto*; die Legende unter dem Kalender zeigt, welche *Einstellungen → Mein Konto*; die Legende unter dem Kalender zeigt, welche
Farbe zu wem gehoert. Farbe zu wem gehört.
* **Teilnehmer** erscheinen mit Foto oder Initiale und sind anklickbar. * **Teilnehmer** erscheinen mit Foto oder Initiale und sind anklickbar.
* **@-Erwaehnungen**: In der Beschreibung `@` tippen, dann Kontakte *und* * **@-Erwähnungen**: In der Beschreibung `@` tippen, dann Kontakte *und*
Benutzer aus der Vorschlagsliste waehlen (Pfeiltasten, Enter). In der Benutzer aus der Vorschlagsliste wählen (Pfeiltasten, Enter). In der
Leseansicht sind sie klickbar - Kontakte oeffnen die Kontaktkarte, Benutzer ein Leseansicht sind sie klickbar - Kontakte öffnen die Kontaktkarte, Benutzer ein
Detailfenster mit naechsten Terminen. Detailfenster mit nächsten Terminen.
* Rechts stehen die **naechsten Termine**, nach Tagen gruppiert. Vergangenes * Rechts stehen die **nächsten Termine**, nach Tagen gruppiert. Vergangenes
fliegt aus der Liste, bleibt im Kalender aber erhalten. fliegt aus der Liste, bleibt im Kalender aber erhalten.
Aendern und loeschen darf nur der **Ersteller** oder ein **Administrator**. Ändern und löschen darf nur der **Ersteller** oder ein **Administrator**.
Anlegen darf jeder. Anlegen darf jeder.
## Kontakte ## Kontakte
Drei Ansichten, davon eine besondere: Drei Ansichten, davon eine besondere:
| Ansicht | wofuer | | Ansicht | wofür |
| --- | --- | | --- | --- |
| **Kachel** | Ueberblick mit Foto, Firma, Status | | **Kachel** | Überblick mit Foto, Firma, Status |
| **Liste** | sortierbar, viele Eintraege | | **Liste** | sortierbar, viele Einträge |
| **Netzwerk** | wer steht mit wem in Kontakt | | **Netzwerk** | wer steht mit wem in Kontakt |
Im **Netzwerk** sind Kreise Kontakte (Farbe = Status, Groesse = Anzahl Im **Netzwerk** sind Kreise Kontakte (Farbe = Status, Größe = Anzahl
Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Gruene Linien Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien
verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem
Kontakt. Kontakt.
Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt, einen Knoten ziehen Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt, einen Knoten ziehen
setzt ihn fest (gruener Punkt), Doppelklick loest ihn wieder. Ueber *Verbindung setzt ihn fest (grüner Punkt), Doppelklick löst ihn wieder. Über *Verbindung
erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab. erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab.
Suche und Statusfilter wirken auch hier. Suche und Statusfilter wirken auch hier.
Sobald Kontakte aus dem Postfach uebernommen wurden, erscheint neben dem Sobald Kontakte aus dem Postfach übernommen wurden, erscheint neben dem
Statusfilter ein **Herkunftsfilter**: *Aus Mails uebernommen* zeigt nur die aus Statusfilter ein **Herkunftsfilter**: *Aus Mails übernommen* zeigt nur die aus
dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten. dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten.
### Aus Mails ### Aus Mails
Der Menuepunkt *Kontakte* klappt zwei Unterpunkte auf: *Alle Kontakte* und Der Menüpunkt *Kontakte* klappt zwei Unterpunkte auf: *Alle Kontakte* und
*Aus Mails*. Der Zaehler am Menuepunkt sagt, wie viele Funde warten. *Aus Mails*. Der Zähler am Menüpunkt sagt, wie viele Funde warten.
In *Aus Mails* stehen die Personen, die der Postfachabgleich in Mails gefunden In *Aus Mails* stehen die Personen, die der Postfachabgleich in Mails gefunden
hat. **Sie sind noch keine Kontakte**: sie erscheinen nicht in der Kontaktliste, hat. **Sie sind noch keine Kontakte**: sie erscheinen nicht in der Kontaktliste,
nicht in Opportunities und nicht im Kalender. Jede Karte zeigt alle gefundenen nicht in Opportunities und nicht im Kalender. Jede Karte zeigt alle gefundenen
Angaben und darunter, aus welcher Mail sie stammen. Angaben und darunter, aus welcher Mail sie stammen.
Zwei Faelle: Zwei Fälle:
* **Weisse Karte** - die Person ist neu. *In Kontakte übernehmen* legt sie als * **Weiße Karte** - die Person ist neu. *In Kontakte übernehmen* legt sie als
Kontakt an, mit Herkunft „Mail". Kontakt an, mit Herkunft „Mail".
* **Gold markierte Karte** - es gibt sie in den Kontakten schon, erkannt an der * **Gold markierte Karte** - es gibt sie in den Kontakten schon, erkannt an der
E-Mail-Adresse oder am Namen. Gold hinterlegte Felder weichen ab; darunter steht E-Mail-Adresse oder am Namen. Gold hinterlegte Felder weichen ab; darunter steht
durchgestrichen, was bisher im Kontakt stand. *Kontakt aktualisieren* oeffnet durchgestrichen, was bisher im Kontakt stand. *Kontakt aktualisieren* öffnet
die Feldauswahl: angehakt sind zunaechst nur die Luecken, also Felder, die im die Feldauswahl: angehakt sind zunächst nur die Lücken, also Felder, die im
Kontakt leer sind. Ein Wert, der etwas Gepflegtes ersetzen wuerde, ist als Kontakt leer sind. Ein Wert, der etwas Gepflegtes ersetzen würde, ist als
*ueberschreibt* gekennzeichnet und muss bewusst angehakt werden - die Angabe *überschreibt* gekennzeichnet und muss bewusst angehakt werden - die Angabe
kann aus einer alten Signatur stammen. *Neu anlegen* erzeugt trotz Dublette kann aus einer alten Signatur stammen. *Neu anlegen* erzeugt trotz Dublette
einen zweiten Kontakt, etwa wenn es zwei Personen mit demselben Namen gibt. einen zweiten Kontakt, etwa wenn es zwei Personen mit demselben Namen gibt.
Weitere Bedienelemente: Weitere Bedienelemente:
* **Alle neuen übernehmen** legt alle weissen Karten auf einmal als Kontakte an; * **Alle neuen übernehmen** legt alle weißen Karten auf einmal als Kontakte an;
gold markierte bleiben unberuehrt. gold markierte bleiben unberührt.
* **Eintrag hier behalten** - normalerweise verschwindet ein Eintrag aus *Aus * **Eintrag hier behalten** - normalerweise verschwindet ein Eintrag aus *Aus
Mails*, sobald er als Kontakt gefuehrt wird. Mit dem Haken bleibt er stehen. Mails*, sobald er als Kontakt geführt wird. Mit dem Haken bleibt er stehen.
* Das **Papierkorbsymbol** entfernt einen Fund. Die Mails bleiben erhalten; taucht * Das **Papierkorbsymbol** entfernt einen Fund. Die Mails bleiben erhalten; taucht
die Person in einer neuen Mail auf, erscheint sie hier wieder. die Person in einer neuen Mail auf, erscheint sie hier wieder.
* Der Filter waehlt zwischen allen Eintraegen, nur neuen Personen und nur * Der Filter wählt zwischen allen Einträgen, nur neuen Personen und nur
Dubletten. Dubletten.
## Mails ## Mails
Das gemeinsame Postfach `crm@perinco.de` liegt hier im CRM. Der Zugriff ist Das gemeinsame Postfach `crm@perinco.de` liegt hier im CRM. Der Zugriff ist
**nur lesend** - im Postfach selbst wird nichts veraendert, keine Mail als gelesen **nur lesend** - im Postfach selbst wird nichts verändert, keine Mail als gelesen
markiert, nichts geloescht. Antworten geht weiter in Outlook; der Link *In Outlook markiert, nichts gelöscht. Antworten geht weiter in Outlook; der Link *In Outlook
oeffnen* springt direkt zur Mail. öffnen* springt direkt zur Mail.
Zwei Reiter oben: Zwei Reiter oben:
### Postfach ### Postfach
Links die Mailliste, rechts die gewaehlte Mail. Die Kennzeichen an einem Links die Mailliste, rechts die gewählte Mail. Die Kennzeichen an einem
Listeneintrag sagen, was noch zu tun ist: Listeneintrag sagen, was noch zu tun ist:
| Kennzeichen | Bedeutung | | Kennzeichen | Bedeutung |
| --- | --- | | --- | --- |
| **1x zugeordnet** | haengt an einer Opportunity oder einem Kontakt | | **1x zugeordnet** | hängt an einer Opportunity oder einem Kontakt |
| **2 Kontakte** | so viele Kontakt-Vorschlaege warten noch auf eine Entscheidung | | **2 Kontakte** | so viele Kontakt-Vorschläge warten noch auf eine Entscheidung |
| 📎 | Anhaenge vorhanden (im Postfach, nicht im CRM) | | 📎 | Anhänge vorhanden (im Postfach, nicht im CRM) |
Das Auswahlfeld filtert die Liste. **Nicht zugeordnet** ist die eigentliche Das Auswahlfeld filtert die Liste. **Nicht zugeordnet** ist die eigentliche
Arbeitsliste: alles, was noch nirgends haengt. Die Suche geht ueber Betreff, Arbeitsliste: alles, was noch nirgends hängt. Die Suche geht über Betreff,
Absender und den kompletten Mailtext; mehrere Woerter wirken als ein Suchbegriff. Absender und den kompletten Mailtext; mehrere Wörter wirken als ein Suchbegriff.
**Zuordnen** haengt die Mail an eine Opportunity und/oder einen Kontakt. Der **Zuordnen** hängt die Mail an eine Opportunity und/oder einen Kontakt. Der
Dialog schlaegt oben vor, was zu den Mailadressen passt - ein Klick auf den Dialog schlägt oben vor, was zu den Mailadressen passt - ein Klick auf den
Vorschlag genuegt. Beides gleichzeitig ist moeglich, jede Zuordnung laesst sich Vorschlag genügt. Beides gleichzeitig ist möglich, jede Zuordnung lässt sich
einzeln wieder mit dem **x** loesen. einzeln wieder mit dem **x** lösen.
Beim Mailinhalt sind externe Bilder zunaechst gesperrt. Das ist Absicht: geladene Beim Mailinhalt sind externe Bilder zunächst gesperrt. Das ist Absicht: geladene
Bilder melden dem Absender, dass die Mail geoeffnet wurde. Ueber *Externe Bilder Bilder melden dem Absender, dass die Mail geöffnet wurde. Über *Externe Bilder
laden* geht es pro Mail. *Groesser* klappt den Inhalt auf, *Nur Text anzeigen* laden* geht es pro Mail. *Größer* klappt den Inhalt auf, *Nur Text anzeigen*
zeigt die Rohfassung ohne Formatierung. zeigt die Rohfassung ohne Formatierung.
Steht an einer Mail **Im Postfach geloescht**, wurde sie in Outlook entfernt. Im Steht an einer Mail **Im Postfach gelöscht**, wurde sie in Outlook entfernt. Im
CRM bleibt sie erhalten, damit Zuordnungen nicht verloren gehen. CRM bleibt sie erhalten, damit Zuordnungen nicht verloren gehen.
### Kontakt-Vorschlaege ### Kontakt-Vorschläge
Aus Absender, Empfaengerliste und Signatur jeder Mail liest die Anwendung Aus Absender, Empfängerliste und Signatur jeder Mail liest die Anwendung
Kontaktdaten. Wer eine echte Mailadresse hat und aus Absender oder Kontaktdaten. Wer eine echte Mailadresse hat und aus Absender oder
Empfaengerliste stammt, landet **automatisch** im Vorbereich Empfängerliste stammt, landet **automatisch** im Vorbereich
[Aus Mails](#aus-mails) - nicht direkt in den Kontakten. Alles Unsichere landet [Aus Mails](#aus-mails) - nicht direkt in den Kontakten. Alles Unsichere landet
hier - typischerweise Personen, die nur mit Telefonnummer im Text genannt werden. hier - typischerweise Personen, die nur mit Telefonnummer im Text genannt werden.
Der Balken rechts ist die Zuversicht: je voller und gruener, je verlaesslicher die Der Balken rechts ist die Zuversicht: je voller und grüner, je verlässlicher die
Angaben. Das Kennzeichen darunter sagt, woher sie stammen (*Absender*, Angaben. Das Kennzeichen darunter sagt, woher sie stammen (*Absender*,
*Empfaenger*, *Signatur*, *Im Text genannt*). *Empfänger*, *Signatur*, *Im Text genannt*).
Zwei Knoepfe: Zwei Knöpfe:
* **Als Kontakt anlegen** - es entsteht sofort ein richtiger Kontakt mit Herkunft * **Als Kontakt anlegen** - es entsteht sofort ein richtiger Kontakt mit Herkunft
„Mail", nicht nur ein Eintrag in *Aus Mails*: hier hat ja schon jemand „Mail", nicht nur ein Eintrag in *Aus Mails*: hier hat ja schon jemand
hingesehen und entschieden. hingesehen und entschieden.
* **Ergaenzen** (statt „anlegen", wenn **Bereits vorhanden** dransteht) - der * **Ergänzen** (statt „anlegen", wenn **Bereits vorhanden** dransteht) - der
vorhandene Kontakt wird um die fehlenden Angaben erweitert. Was schon gepflegt vorhandene Kontakt wird um die fehlenden Angaben erweitert. Was schon gepflegt
ist, bleibt unangetastet. ist, bleibt unangetastet.
* **Verwerfen** - der Vorschlag verschwindet aus der Liste. * **Verwerfen** - der Vorschlag verschwindet aus der Liste.
@@ -227,18 +231,18 @@ Zwei Knoepfe:
Eigene Kollegen (`@perinco.de`) und Funktionsadressen wie `info@` oder Eigene Kollegen (`@perinco.de`) und Funktionsadressen wie `info@` oder
`no-reply@` werden nie vorgeschlagen. `no-reply@` werden nie vorgeschlagen.
Ist die Liste leer und die automatische Uebernahme an, ist das der Normalzustand: Ist die Liste leer und die automatische Übernahme an, ist das der Normalzustand:
es gab nichts zu entscheiden. es gab nichts zu entscheiden.
## Scanner ## Scanner
Durchsucht Projektboersen und bewertet jeden Fund mit KI gegen die Lebenslaeufe Durchsucht Projektbörsen und bewertet jeden Fund mit KI gegen die Lebensläufe
der Berater. der Berater.
### Dashboard ### Dashboard
Oben vier Kennzahlen, darunter die Trefferliste. Der Knopf **Jetzt scannen** Oben vier Kennzahlen, darunter die Trefferliste. Der Knopf **Jetzt scannen**
startet sofort einen Suchlauf; laeuft schon einer, sagt die Anwendung das. startet sofort einen Suchlauf; läuft schon einer, sagt die Anwendung das.
Die Filterzeile: Die Filterzeile:
@@ -246,123 +250,123 @@ 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 %** | schwaechere Treffer ausblenden | | **Anzeige ab %** | schwächere Treffer ausblenden |
| **Hinweis ab %** | ab wann Signalton und E-Mail | | **Hinweis ab %** | ab wann Signalton und E-Mail |
Die letzten zwei Werte werden **gespeichert** und gelten auch beim naechsten Mal Die letzten zwei Werte werden **gespeichert** und gelten auch beim nächsten Mal
und in den Einstellungen. Rechts daneben steht `sichtbar / gesamt`. und in den Einstellungen. Rechts daneben steht `sichtbar / gesamt`.
Ein Klick auf eine Zeile oeffnet die Details: Projektbeschreibung, die Ein Klick auf eine Zeile öffnet die Details: Projektbeschreibung, die
KI-Auswertung mit Pro und Contra, dazu die Knoepfe *Gesehen*, *Beworben*, KI-Auswertung mit Pro und Contra, dazu die Knöpfe *Gesehen*, *Beworben*,
*Zurueck auf Neu*, *Boerse oeffnen*, *Passt nicht* und *Treffer loeschen*. *Zurück auf Neu*, *Börse öffnen*, *Passt nicht* und *Treffer löschen*.
Ueber der Liste steht der Betriebszustand: *automatische Suche aktiv*, *nur Über der Liste steht der Betriebszustand: *automatische Suche aktiv*, *nur
manuelle Suche*, *pausiert* oder *Suchlauf laeuft*. manuelle Suche*, *pausiert* oder *Suchlauf läuft*.
### Ton bei neuen Treffern ### Ton bei neuen Treffern
Findet der Scanner einen Treffer ueber dem Wert *Hinweis ab %*, spielt das Findet der Scanner einen Treffer über dem Wert *Hinweis ab %*, spielt das
Dashboard einen Signalton und zeigt ein Hinweisbanner. Drei Dinge muss man dazu Dashboard einen Signalton und zeigt ein Hinweisbanner. Drei Dinge muss man dazu
wissen: wissen:
1. **Nur im Dashboard.** Wer auf Opportunities oder Kontakte wechselt, hoert 1. **Nur im Dashboard.** Wer auf Opportunities oder Kontakte wechselt, hört
nichts - und bekommt es auch spaeter nicht nachgemeldet. nichts - und bekommt es auch später nicht nachgemeldet.
2. **In einem anderen Browsertab** klingelt es weiter, aber verzoegert: der 2. **In einem anderen Browsertab** klingelt es weiter, aber verzögert: der
Browser bremst versteckte Tabs auf etwa eine Pruefung pro Minute. Ausserdem Browser bremst versteckte Tabs auf etwa eine Prüfung pro Minute. Ausserdem
muss man vorher einmal irgendwo auf der Seite geklickt haben, sonst laesst der muss man vorher einmal irgendwo auf der Seite geklickt haben, sonst lässt der
Browser keinen Ton zu. Browser keinen Ton zu.
3. **Ist der Tab geschlossen, passiert nichts.** Wer unabhaengig vom Browser 3. **Ist der Tab geschlossen, passiert nichts.** Wer unabhängig vom Browser
benachrichtigt werden will, schaltet die **E-Mail** in den Einstellungen ein. benachrichtigt werden will, schaltet die **E-Mail** in den Einstellungen ein.
### Unpassend ### Unpassend
Alles, was ueber *Passt nicht* aussortiert wurde. Von hier bringt *Zurueck ins Alles, was über *Passt nicht* aussortiert wurde. Von hier bringt *Zurück ins
Dashboard* einen Treffer wieder zurueck, oder er wird endgueltig geloescht. Dashboard* einen Treffer wieder zurück, oder er wird endgültig gelöscht.
### Suchagenten ### Suchagenten
Ein Suchagent ist ein Berater: Ein Suchagent ist ein Berater:
| Feld | wofuer | | Feld | wofür |
| --- | --- | | --- | --- |
| **Name** | erscheint an jedem Treffer | | **Name** | erscheint an jedem Treffer |
| **E-Mail** | Ziel der Benachrichtigung fuer diesen Berater | | **E-Mail** | Ziel der Benachrichtigung für diesen Berater |
| **Suchbegriffe** | kommagetrennt, damit wird auf den Boersen gesucht | | **Suchbegriffe** | kommagetrennt, damit wird auf den Börsen gesucht |
| **Ausschluss-Begriffe** | kommagetrennt; Projekte mit diesen Woertern werden uebersprungen | | **Ausschluss-Begriffe** | kommagetrennt; Projekte mit diesen Wörtern werden übersprungen |
| **Lebenslauf (PDF)** | wird als Text ausgelesen und gegen jedes Projekt bewertet | | **Lebenslauf (PDF)** | wird als Text ausgelesen und gegen jedes Projekt bewertet |
| **aktiv** | nur aktive Agenten suchen im Hintergrund | | **aktiv** | nur aktive Agenten suchen im Hintergrund |
Ohne Lebenslauf kann ein Agent nichts bewerten. Die Ausschluss-Begriffe sind Ohne Lebenslauf kann ein Agent nichts bewerten. Die Ausschluss-Begriffe sind
nicht nur Bequemlichkeit: sie greifen **vor** der KI-Bewertung und sparen damit nicht nur Bequemlichkeit: sie greifen **vor** der KI-Bewertung und sparen damit
echte Kosten. „Arbeitnehmerueberlassung" oder „Festanstellung" sind typische echte Kosten. „Arbeitnehmerüberlassung" oder „Festanstellung" sind typische
Kandidaten. Kandidaten.
> **Loeschen nimmt die Treffer mit.** Wird ein Suchagent geloescht, verschwinden > **Löschen nimmt die Treffer mit.** Wird ein Suchagent gelöscht, verschwinden
> alle seine bisherigen Treffer und sein Lebenslauf mit ihm - die Datenbank raeumt > alle seine bisherigen Treffer und sein Lebenslauf mit ihm - die Datenbank räumt
> das automatisch auf. Die gefundenen **Projekte** bleiben erhalten, sie gehoeren > das automatisch auf. Die gefundenen **Projekte** bleiben erhalten, sie gehören
> nicht zu einem einzelnen Agenten. Wer einen Berater nur vorübergehend aus der > nicht zu einem einzelnen Agenten. Wer einen Berater nur vorübergehend aus der
> Suche nehmen will, setzt ihn besser auf **inaktiv** statt ihn zu loeschen: dann > Suche nehmen will, setzt ihn besser auf **inaktiv** statt ihn zu löschen: dann
> sucht er nicht mehr, die Treffer bleiben aber sichtbar. > sucht er nicht mehr, die Treffer bleiben aber sichtbar.
### Boersen-Konten ### Börsen-Konten
Zeigt fuer jede Boerse, ob die Verbindung steht. Zwei Arten: Zeigt für jede Börse, ob die Verbindung steht. Zwei Arten:
* **Oeffentliche Portale** (Hays, Etengo, …) brauchen keinen Login. * **Öffentliche Portale** (Hays, Etengo, …) brauchen keinen Login.
* **Geschuetzte Portale** (Freelance.de, Freelancermap.de) brauchen eine * **Geschützte Portale** (Freelance.de, Freelancermap.de) brauchen eine
angemeldete Sitzung. Dafuer exportiert man die Cookies mit der angemeldete Sitzung. Dafür exportiert man die Cookies mit der
Browser-Erweiterung *Cookie-Editor* als JSON und fuegt sie unter Browser-Erweiterung *Cookie-Editor* als JSON und fügt sie unter
**Cookie-Import** ein. **Cookie-Import** ein.
*Jetzt pruefen* testet eine Verbindung sofort. Steht dort **Abgelaufen / *Jetzt prüfen* testet eine Verbindung sofort. Steht dort **Abgelaufen /
CAPTCHA**, hilft nur ein neuer Cookie-Import. CAPTCHA**, hilft nur ein neuer Cookie-Import.
Die Zugangsdaten (Benutzername, Passwort) sind optional - der Scraper versucht Die Zugangsdaten (Benutzername, Passwort) sind optional - der Scraper versucht
damit im Notfall eine eigene Anmeldung. Passwoerter liegen verschluesselt in der damit im Notfall eine eigene Anmeldung. Passwörter liegen verschlüsselt in der
Datenbank. Datenbank.
## Einstellungen ## Einstellungen
| Reiter | Wer | Inhalt | | Reiter | Wer | Inhalt |
| --- | --- | --- | | --- | --- | --- |
| **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, loeschen | | **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, löschen |
| **Mein Konto** | alle | Foto, Kalenderfarbe, Passwort aendern | | **Mein Konto** | alle | Foto, Kalenderfarbe, Passwort ändern |
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktuebernahme | | **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme |
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schluessel, Ton, E-Mail | | **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail |
| **Whisper** | alle | Spracherkennung fuer das Diktat einrichten | | **Whisper** | alle | Spracherkennung für das Diktat einrichten |
Im Reiter **Scanner** steht das **Suchintervall (Minuten)** - also wie oft der Im Reiter **Scanner** steht das **Suchintervall (Minuten)** - also wie oft der
Hintergrund-Scanner die Boersen durchsucht. Hintergrund-Scanner die Börsen durchsucht.
Darueber der Hauptschalter **Scanner aktiviert**. Ist er aus, sucht die Anwendung Darüber der Hauptschalter **Scanner aktiviert**. Ist er aus, sucht die Anwendung
nicht mehr: kein Lauf im Hintergrund, und *Jetzt scannen* antwortet mit einem nicht mehr: kein Lauf im Hintergrund, und *Jetzt scannen* antwortet mit einem
Hinweis statt zu starten. Der Zustand steht auch oben im Scanner-Dashboard, damit Hinweis statt zu starten. Der Zustand steht auch oben im Scanner-Dashboard, damit
man nicht raetselt, warum keine neuen Treffer kommen. man nicht rätselt, warum keine neuen Treffer kommen.
Im Reiter **Postfach** steht oben, ob die Verbindung zu Microsoft 365 steht. Im Reiter **Postfach** steht oben, ob die Verbindung zu Microsoft 365 steht.
**Verbindung testen** prueft beides: die Anmeldung *und* den tatsaechlichen **Verbindung testen** prüft beides: die Anmeldung *und* den tatsächlichen
Zugriff auf `crm@perinco.de`. Schlaegt etwas fehl, nennt die Meldung den Zugriff auf `crm@perinco.de`. Schlägt etwas fehl, nennt die Meldung den
wahrscheinlichen Grund. Darunter lassen sich Intervall, Zeitraum des ersten wahrscheinlichen Grund. Darunter lassen sich Intervall, Zeitraum des ersten
Durchlaufs und die automatische Kontaktuebernahme einstellen. Durchlaufs und die automatische Kontaktübernahme einstellen.
Die Zugangsdaten selbst stehen **nicht** in der Oberflaeche, sondern in der Die Zugangsdaten selbst stehen **nicht** in der Oberfläche, sondern in der
Serverkonfiguration - sie werden beim Start gelesen. Fehlt einer, sagt die Seite Serverkonfiguration - sie werden beim Start gelesen. Fehlt einer, sagt die Seite
welcher. welcher.
Foto und Kalenderfarbe werden **sofort** gespeichert, ohne Speichern-Knopf. Der Foto und Kalenderfarbe werden **sofort** gespeichert, ohne Speichern-Knopf. Der
letzte aktive Administrator kann sich nicht selbst degradieren oder loeschen - letzte aktive Administrator kann sich nicht selbst degradieren oder löschen -
das verhindert der Server. das verhindert der Server.
## Diktat ## Diktat
Ueberall, wo laengerer Text entsteht, sitzt ein Mikrofon-Knopf. Beim ersten Mal Überall, wo längerer Text entsteht, sitzt ein Mikrofon-Knopf. Beim ersten Mal
steht dort *Diktat einrichten* und fuehrt in die Einstellungen: dort einmal steht dort *Diktat einrichten* und führt in die Einstellungen: dort einmal
**Sprachmodell laden** klicken, rund 759 MB, danach ist es dauerhaft in diesem **Sprachmodell laden** klicken, rund 759 MB, danach ist es dauerhaft in diesem
Browser. Browser.
Die Erkennung laeuft **komplett auf dem eigenen Rechner**. Aufnahmen verlassen Die Erkennung läuft **komplett auf dem eigenen Rechner**. Aufnahmen verlassen
das Geraet nicht und werden nirgends gespeichert. Auf einem anderen Rechner oder das Gerät nicht und werden nirgends gespeichert. Auf einem anderen Rechner oder
in einem anderen Browser faellt der Download erneut an - er gehoert zum Browser, in einem anderen Browser fällt der Download erneut an - er gehört zum Browser,
nicht zum Benutzerkonto. nicht zum Benutzerkonto.
## Wenn etwas nicht klappt ## Wenn etwas nicht klappt
@@ -373,10 +377,10 @@ nicht zum Benutzerkonto.
| Trefferliste ist leer | *Anzeige ab %* zu hoch oder *Zeitraum* zu kurz | | Trefferliste ist leer | *Anzeige ab %* zu hoch oder *Zeitraum* zu kurz |
| Kein Ton bei Treffern | Dashboard offen? Einmal auf der Seite geklickt? Wert *Hinweis ab %* erreicht? | | Kein Ton bei Treffern | Dashboard offen? Einmal auf der Seite geklickt? Wert *Hinweis ab %* erreicht? |
| Mikrofon-Knopf fehlt | kein Mikrofon oder keine HTTPS-Verbindung | | Mikrofon-Knopf fehlt | kein Mikrofon oder keine HTTPS-Verbindung |
| Boerse zeigt „Abgelaufen" | Cookies neu importieren | | Börse zeigt „Abgelaufen" | Cookies neu importieren |
| „Ein Suchlauf laeuft bereits" | kurz warten, es laeuft nur einer zur Zeit | | „Ein Suchlauf läuft bereits" | kurz warten, es läuft nur einer zur Zeit |
| Aendern nicht erlaubt | bei Opportunities darf nur Ersteller, Zustaendiger oder Admin | | Ändern nicht erlaubt | bei Opportunities darf nur Ersteller, Zuständiger oder Admin |
| Keine Mails sichtbar | steht oben ein Hinweisbalken? Sonst Admin bitten, in *Einstellungen &rarr; Postfach* die Verbindung zu testen | | Keine Mails sichtbar | steht oben ein Hinweisbalken? Sonst Admin bitten, in *Einstellungen &rarr; Postfach* die Verbindung zu testen |
| „Erstabgleich laeuft noch" | aeltere Mails werden in mehreren Durchlaeufen nachgeholt, die Liste waechst noch | | „Erstabgleich läuft noch" | ältere Mails werden in mehreren Durchläufen nachgeholt, die Liste wächst noch |
| Bilder in einer Mail fehlen | *Externe Bilder laden* klicken - sie sind absichtlich gesperrt | | Bilder in einer Mail fehlen | *Externe Bilder laden* klicken - sie sind absichtlich gesperrt |
| Kontakt-Vorschlaege bleiben leer | Normalfall: sichere Vorschlaege wandern automatisch nach *Kontakte › Aus Mails* und tauchen hier nicht auf | | Kontakt-Vorschläge bleiben leer | Normalfall: sichere Vorschläge wandern automatisch nach *Kontakte › Aus Mails* und tauchen hier nicht auf |
+49 -49
View File
@@ -1,27 +1,27 @@
# 08 Anleitung fuer Entwickler # 08 Anleitung für Entwickler
Fuer jemanden, der das Projekt zum ersten Mal oeffnet. Was man braucht, wie man es Für jemanden, der das Projekt zum ersten Mal öffnet. Was man braucht, wie man es
startet, wo man was aendert - und welche Fallen es gibt. startet, wo man was ändert - und welche Fallen es gibt.
## In zwei Minuten zum Bild ## In zwei Minuten zum Bild
* **Ein** Vite-Frontend (React 19, TypeScript, Tailwind 4), **ein** Express-Backend, * **Ein** Vite-Frontend (React 19, TypeScript, Tailwind 4), **ein** Express-Backend,
**zwei** MariaDB-Datenbanken. **zwei** MariaDB-Datenbanken.
* Kein Router im Frontend: `App.tsx` haelt die Ansicht in einem `useState`. Die * Kein Router im Frontend: `App.tsx` hält die Ansicht in einem `useState`. Die
App liegt hinter einer Anmeldung, tiefe Links braucht sie nicht. App liegt hinter einer Anmeldung, tiefe Links braucht sie nicht.
* Kein ORM im CRM-Teil - dort schreibt das Backend SQL mit `mysql2`. Der * Kein ORM im CRM-Teil - dort schreibt das Backend SQL mit `mysql2`. Der
Scanner-Teil benutzt Sequelize, weil er 1:1 aus einer anderen Anwendung Scanner-Teil benutzt Sequelize, weil er 1:1 aus einer anderen Anwendung
uebernommen wurde. übernommen wurde.
* Alles auf Deutsch: Bezeichner, Kommentare, Meldungen. Kommentare erklaeren das * Alles auf Deutsch: Bezeichner, Kommentare, Meldungen. Kommentare erklären das
**Warum**, nicht das Was. **Warum**, nicht das Was.
## Voraussetzungen ## Voraussetzungen
| | Version | | | Version |
| --- | --- | | --- | --- |
| Node | 20 oder neuer (Server laeuft 20.20) | | Node | 20 oder neuer (Server läuft 20.20) |
| MariaDB | 10.11 oder neuer, erreichbar mit Benutzer und Passwort | | MariaDB | 10.11 oder neuer, erreichbar mit Benutzer und Passwort |
| Browser | Chrome/Edge fuer WebGPU beim Diktat (sonst laeuft es auf der CPU) | | Browser | Chrome/Edge für WebGPU beim Diktat (sonst läuft es auf der CPU) |
## Lokal starten ## Lokal starten
@@ -45,10 +45,10 @@ Was dabei von selbst passiert:
* Fehlt `ADMIN_PASSWORD`, wird beim ersten Start ein Zufallspasswort erzeugt und * Fehlt `ADMIN_PASSWORD`, wird beim ersten Start ein Zufallspasswort erzeugt und
**einmalig ins Log geschrieben**. Dort nachsehen, sonst kommt man nicht rein. **einmalig ins Log geschrieben**. Dort nachsehen, sonst kommt man nicht rein.
Ohne MariaDB startet das Backend nicht. Das Frontend laesst sich trotzdem Ohne MariaDB startet das Backend nicht. Das Frontend lässt sich trotzdem
ansehen - siehe *Oberflaeche pruefen ohne Datenbank* weiter unten. ansehen - siehe *Oberfläche prüfen ohne Datenbank* weiter unten.
## Pruefen ## Prüfen
```bash ```bash
npm run lint # = tsc --noEmit, das ist die Wahrheit npm run lint # = tsc --noEmit, das ist die Wahrheit
@@ -56,15 +56,15 @@ npm run build # muss ohne Fehler durchlaufen
cd server && npm test # Pruefungen der Postfachanbindung cd server && npm test # Pruefungen der Postfachanbindung
``` ```
Automatische Tests gibt es nur fuer **einen** Bereich: `server/test/mail.test.mjs` Automatische Tests gibt es nur für **einen** Bereich: `server/test/mail.test.mjs`
prueft die Kontaktextraktion aus Mails und den Graph-Client. Beides laeuft prüft die Kontaktextraktion aus Mails und den Graph-Client. Beides läuft
unbeaufsichtigt im Hintergrund und laesst sich von Hand kaum nachstellen - ein unbeaufsichtigt im Hintergrund und lässt sich von Hand kaum nachstellen - ein
falsch erkannter Name landet still in der Kontaktliste, und ein 429 vom echten falsch erkannter Name landet still in der Kontaktliste, und ein 429 vom echten
Dienst kommt genau dann, wenn niemand hinsieht. Kein Test-Framework, reines Node, Dienst kommt genau dann, wenn niemand hinsieht. Kein Test-Framework, reines Node,
keine Datenbank. keine Datenbank.
Fuer alles andere gilt: `tsc`, ein Build und ein Blick in die Oberflaeche - notfalls Für alles andere gilt: `tsc`, ein Build und ein Blick in die Oberfläche - notfalls
mit dem Stub aus [Oberflaeche pruefen ohne Datenbank](#oberflaeche-pruefen-ohne-datenbank). mit dem Stub aus [Oberfläche prüfen ohne Datenbank](#oberflaeche-pruefen-ohne-datenbank).
## Aufbau ## Aufbau
@@ -86,7 +86,7 @@ docs/ diese Dokumentation
deploy/ Apache-Vhosts, systemd-Unit, Deployskript deploy/ Apache-Vhosts, systemd-Unit, Deployskript
``` ```
## Wie eine Anfrage laeuft ## Wie eine Anfrage läuft
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@@ -109,25 +109,25 @@ sequenceDiagram
A-->>K: typisiertes Objekt A-->>K: typisiertes Objekt
``` ```
Jede Route liefert den **vollstaendigen, angereicherten Datensatz** zurueck (mit Jede Route liefert den **vollständigen, angereicherten Datensatz** zurück (mit
`created_by_name`, `owner_name`, Ansprechpartnern, Teilnehmern). Das Frontend muss `created_by_name`, `owner_name`, Ansprechpartnern, Teilnehmern). Das Frontend muss
danach nichts nachladen. danach nichts nachladen.
## Etwas hinzufuegen ## Etwas hinzufügen
### Neues Feld an einem Kontakt ### Neues Feld an einem Kontakt
1. `server/src/schema.sql`: Spalte mit `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 1. `server/src/schema.sql`: Spalte mit `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
ergaenzen - das Statement muss beim naechsten Start wiederholbar sein. ergänzen - das Statement muss beim nächsten Start wiederholbar sein.
2. `server/src/routes/leads.js`: Feld in die Listen `TEXT_FIELDS` bzw. in 2. `server/src/routes/leads.js`: Feld in die Listen `TEXT_FIELDS` bzw. in
`INSERT`/`UPDATE` und in den `SELECT` aufnehmen. `INSERT`/`UPDATE` und in den `SELECT` aufnehmen.
3. `src/types.ts`: Feld ins `Lead`-Interface. 3. `src/types.ts`: Feld ins `Lead`-Interface.
4. `src/components/leads/LeadModal.tsx`: Eingabefeld, `inputClass` benutzen. 4. `src/components/leads/LeadModal.tsx`: Eingabefeld, `inputClass` benutzen.
5. Wenn es in Kachel oder Liste sichtbar sein soll: `LeadGrid.tsx`, `LeadList.tsx`. 5. Wenn es in Kachel oder Liste sichtbar sein soll: `LeadGrid.tsx`, `LeadList.tsx`.
### Neuer Menuepunkt ### Neuer Menüpunkt
1. `src/types.ts`: Wert in `ViewState` ergaenzen. 1. `src/types.ts`: Wert in `ViewState` ergänzen.
2. `src/components/Sidebar.tsx`: Eintrag in `CATEGORY_ITEMS` (Name + SVG-Pfad). 2. `src/components/Sidebar.tsx`: Eintrag in `CATEGORY_ITEMS` (Name + SVG-Pfad).
3. `src/App.tsx`: Komponente rendern und einen Untertitel setzen. 3. `src/App.tsx`: Komponente rendern und einen Untertitel setzen.
@@ -137,7 +137,7 @@ Kopfzeile. Wer eine abweichende Beschriftung braucht, setzt `label` am `MenuItem
### Neuer Reiter in den Einstellungen ### Neuer Reiter in den Einstellungen
1. `src/types.ts`: Wert in `SettingsTab`; nur fuer Admins zusaetzlich in 1. `src/types.ts`: Wert in `SettingsTab`; nur für Admins zusätzlich in
`ADMIN_SETTINGS_TABS`. `ADMIN_SETTINGS_TABS`.
2. `src/components/settings/Settings.tsx`: Eintrag in `TABS` und Rendering. 2. `src/components/settings/Settings.tsx`: Eintrag in `TABS` und Rendering.
@@ -147,24 +147,24 @@ Ein Sprung dorthin aus einer beliebigen Tiefe:
### Neuer Endpunkt ### Neuer Endpunkt
1. Router in `server/src/routes/` anlegen, `router.use(requireAuth)` nicht 1. Router in `server/src/routes/` anlegen, `router.use(requireAuth)` nicht
vergessen, `requireAdmin` wo noetig. vergessen, `requireAdmin` wo nötig.
2. In `server/src/server.js` einhaengen - **vor** dem `/api`-404-Fallback. 2. In `server/src/server.js` einhängen - **vor** dem `/api`-404-Fallback.
3. In `src/lib/api.ts` eine typisierte Funktion ergaenzen. 3. In `src/lib/api.ts` eine typisierte Funktion ergänzen.
### Neue Einstellung im CRM ### Neue Einstellung im CRM
Nur fuer Werte, die im Betrieb verstellbar sein sollen. **Zugangsdaten gehoeren Nur für Werte, die im Betrieb verstellbar sein sollen. **Zugangsdaten gehören
nicht dazu** - die stehen in `server/.env` und werden beim Start gelesen, damit ein nicht dazu** - die stehen in `server/.env` und werden beim Start gelesen, damit ein
Datenbank-Dump keine Geheimnisse enthaelt. Datenbank-Dump keine Geheimnisse enthält.
1. Schluessel samt Vorgabe in das Vorgabenobjekt des Bereichs eintragen (Muster: 1. Schlüssel samt Vorgabe in das Vorgabenobjekt des Bereichs eintragen (Muster:
`MAIL_SETTING_DEFAULTS` in `server/src/mail/config.js`). Es ist gleichzeitig die `MAIL_SETTING_DEFAULTS` in `server/src/mail/config.js`). Es ist gleichzeitig die
Positivliste des Speicher-Endpunkts. Positivliste des Speicher-Endpunkts.
2. Lesen mit `getSettings(defaults)` aus `server/src/settings.js`, schreiben mit 2. Lesen mit `getSettings(defaults)` aus `server/src/settings.js`, schreiben mit
`setSetting()`. Zahlen und Schalter mit `asInt`/`asBool` wandeln. `setSetting()`. Zahlen und Schalter mit `asInt`/`asBool` wandeln.
3. Feld im passenden Einstellungsreiter ergaenzen. 3. Feld im passenden Einstellungsreiter ergänzen.
Die Tabelle `crm_settings` legt `schema.sql` an; ein neuer Schluessel braucht keine Die Tabelle `crm_settings` legt `schema.sql` an; ein neuer Schlüssel braucht keine
Migration. Migration.
## Fallen ## Fallen
@@ -173,26 +173,26 @@ Diese Punkte haben real Zeit gekostet:
| Falle | Was passiert | | Falle | Was passiert |
| --- | --- | | --- | --- |
| **Reihenfolge in `server.js`** | Der 404-Fallback `app.use('/api', ...)` faengt alles, was danach registriert wird. Neue Router davor einhaengen. | | **Reihenfolge in `server.js`** | Der 404-Fallback `app.use('/api', ...)` fängt alles, was danach registriert wird. Neue Router davor einhängen. |
| **`Content-Type` bei FormData** | `apiRequest` setzt JSON nur, wenn der Body **keine** `FormData` ist - sonst fehlt die multipart-Grenze und der Upload scheitert. | | **`Content-Type` bei FormData** | `apiRequest` setzt JSON nur, wenn der Body **keine** `FormData` ist - sonst fehlt die multipart-Grenze und der Upload scheitert. |
| **Dateien in neuen Tabs** | Ein `<a href>` schickt kein Bearer-Token. Deshalb holt `openAgentCv()` das PDF per `fetch` und oeffnet einen Blob. | | **Dateien in neuen Tabs** | Ein `<a href>` schickt kein Bearer-Token. Deshalb holt `openAgentCv()` das PDF per `fetch` und öffnet einen Blob. |
| **Leerzeichen in Pfaden** | Der alte Scanner liegt unter `/var/www/Projekt Scanner/…`. In der `.env` muss der Wert gequotet sein, sonst schneidet systemd ihn am Leerzeichen ab. | | **Leerzeichen in Pfaden** | Der alte Scanner liegt unter `/var/www/Projekt Scanner/…`. In der `.env` muss der Wert gequotet sein, sonst schneidet systemd ihn am Leerzeichen ab. |
| **`.env` ist gleichzeitig `EnvironmentFile`** | Also kein `export`, keine Shell-Ausdruecke, Werte mit Leerzeichen in Anfuehrungszeichen. | | **`.env` ist gleichzeitig `EnvironmentFile`** | Also kein `export`, keine Shell-Ausdrücke, Werte mit Leerzeichen in Anführungszeichen. |
| **Zeitstempel koennen nur vorziehen** | Ein „vorgealterter" `lastRunTime` verzoegert keinen Cronlauf. Wer verzoegern will, braucht eine eigene Frist. | | **Zeitstempel können nur vorziehen** | Ein „vorgealterter" `lastRunTime` verzögert keinen Cronlauf. Wer verzögern will, braucht eine eigene Frist. |
| **Sperrfristen bei jedem Start neu** | Frueher schob jedes Deployment den faelligen Scan um 30 Minuten - er fand nie statt. Deshalb liegt `last_scan_at` in der Datenbank. | | **Sperrfristen bei jedem Start neu** | Früher schob jedes Deployment den fälligen Scan um 30 Minuten - er fand nie statt. Deshalb liegt `last_scan_at` in der Datenbank. |
| **`sequelize.sync({ alter: true })`** | Legt bei jedem Start Unique-Indizes neu an und sprengt irgendwann MySQLs Grenze von 64 Keys. Nur `sync()` benutzen. | | **`sequelize.sync({ alter: true })`** | Legt bei jedem Start Unique-Indizes neu an und sprengt irgendwann MySQLs Grenze von 64 Keys. Nur `sync()` benutzen. |
| **Kaskaden beim Loeschen** | Einen Suchagenten loeschen loescht alle seine Treffer mit. Inaktiv setzen ist meist gemeint. | | **Kaskaden beim Löschen** | Einen Suchagenten löschen löscht alle seine Treffer mit. Inaktiv setzen ist meist gemeint. |
| **Doppelte Scanner-Laeufe** | Scheduler und *Jetzt scannen* teilen eine Sperre. Ohne sie zahlt man jede KI-Bewertung zweimal. | | **Doppelte Scanner-Läufe** | Scheduler und *Jetzt scannen* teilen eine Sperre. Ohne sie zahlt man jede KI-Bewertung zweimal. |
| **`allow-same-origin` bei `srcDoc`** | Ein so eingebettetes Dokument erbt die Origin der Anwendung und kann `localStorage` lesen - also das Anmeldetoken. Fremdes HTML gehoert in `sandbox=""` ohne jedes Flag. Der Preis: die Inhaltshoehe ist nicht auslesbar, deshalb hat der Mailrahmen eine feste Hoehe. | | **`allow-same-origin` bei `srcDoc`** | Ein so eingebettetes Dokument erbt die Origin der Anwendung und kann `localStorage` lesen - also das Anmeldetoken. Fremdes HTML gehört in `sandbox=""` ohne jedes Flag. Der Preis: die Inhaltshöhe ist nicht auslesbar, deshalb hat der Mailrahmen eine feste Höhe. |
| **`/me` unter App-only** | Es gibt keinen Benutzer im Token, also auch kein `/me` - jeder Graph-Aufruf muss `/users/{postfach}/…` lauten. Genau daran ist das Altsystem gescheitert, still, weil die Antwort nicht geprueft wurde. | | **`/me` unter App-only** | Es gibt keinen Benutzer im Token, also auch kein `/me` - jeder Graph-Aufruf muss `/users/{postfach}/…` lauten. Genau daran ist das Altsystem gescheitert, still, weil die Antwort nicht geprüft wurde. |
| **UTC neben Wandzeit** | `crm_mail_messages.received_at` steht in UTC (so liefert Graph es), `crm_events.starts_at` in lokaler Wandzeit. Beim Lesen im Frontend `parseMailDate()` benutzen, sonst ist die Anzeige um Stunden verschoben. | | **UTC neben Wandzeit** | `crm_mail_messages.received_at` steht in UTC (so liefert Graph es), `crm_events.starts_at` in lokaler Wandzeit. Beim Lesen im Frontend `parseMailDate()` benutzen, sonst ist die Anzeige um Stunden verschoben. |
| **Aufzaehlungen in der Datenbank sind deutsch** | `crm_mail_links.origin` (`manuell`/`automatisch`), Vorschlagsstatus (`offen`, `uebernommen`, `verworfen`, `dublette`). Wer englische Werte schickt, bekommt einen Datenbankfehler statt einer Validierungsmeldung. | | **Aufzählungen in der Datenbank sind deutsch** | `crm_mail_links.origin` (`manuell`/`automatisch`), Vorschlagsstatus (`offen`, `uebernommen`, `verworfen`, `dublette`). Wer englische Werte schickt, bekommt einen Datenbankfehler statt einer Validierungsmeldung. |
## Oberflaeche pruefen ohne Datenbank ## Oberfläche prüfen ohne Datenbank
Fuer Bildschirmfotos und Klickwege genuegt ein Stub: ein kleiner `node:http`-Server, Für Bildschirmfotos und Klickwege genügt ein Stub: ein kleiner `node:http`-Server,
der `dist/` ausliefert und auf `/api/...` erfundene Daten zurueckgibt. Token vorab der `dist/` ausliefert und auf `/api/...` erfundene Daten zurückgibt. Token vorab
setzen, dann ist die Anmeldung uebersprungen: setzen, dann ist die Anmeldung übersprungen:
```js ```js
await page.goto(BASE); await page.goto(BASE);
@@ -208,18 +208,18 @@ Browser-Download.
* **Deutsch** in Bezeichnern, Kommentaren, Meldungen. Fachbegriffe bleiben * **Deutsch** in Bezeichnern, Kommentaren, Meldungen. Fachbegriffe bleiben
englisch (`Match`, `Opportunity`, `Lead`). englisch (`Match`, `Opportunity`, `Lead`).
* **Kommentare erklaeren das Warum.** Was der Code tut, steht im Code. * **Kommentare erklären das Warum.** Was der Code tut, steht im Code.
* **Kein `dark:` im Markup.** Nur die Skalen `ink-*`, `brand-*`, `canvas`, * **Kein `dark:` im Markup.** Nur die Skalen `ink-*`, `brand-*`, `canvas`,
`surface` benutzen, den Rest macht `index.css`. `surface` benutzen, den Rest macht `index.css`.
* **Gemeinsame Klassen** statt kopierter Utility-Ketten: `inputClass`, * **Gemeinsame Klassen** statt kopierter Utility-Ketten: `inputClass`,
`labelClass` aus `components/leads/leadUi.tsx`, dazu `cardClass`, `labelClass` aus `components/leads/leadUi.tsx`, dazu `cardClass`,
`primaryButtonClass` aus `components/scanner/scannerUi.tsx`. `primaryButtonClass` aus `components/scanner/scannerUi.tsx`.
* **Meldungen als Toast**, Loeschabfragen ueber `ConfirmDialog` - nie `alert()` * **Meldungen als Toast**, Löschabfragen über `ConfirmDialog` - nie `alert()`
oder `confirm()`. oder `confirm()`.
* **Rechte immer doppelt**: Oberflaeche blendet aus, Backend antwortet mit 403. * **Rechte immer doppelt**: Oberfläche blendet aus, Backend antwortet mit 403.
## Deployment ## Deployment
Siehe [06 Betrieb](06-betrieb.md). Kurzform: lokal bauen, `deploy/deploy.ps1` Siehe [06 Betrieb](06-betrieb.md). Kurzform: lokal bauen, `deploy/deploy.ps1`
laedt `dist/` und `server/src` hoch und startet den Dienst neu. Auf dem Server lädt `dist/` und `server/src` hoch und startet den Dienst neu. Auf dem Server
wird **nicht** gebaut - dort sind RAM und Swap knapp. wird **nicht** gebaut - dort sind RAM und Swap knapp.
+68 -68
View File
@@ -1,6 +1,6 @@
# 09 Quellcode-Referenz # 09 Quellcode-Referenz
Was in welcher Datei steckt und welche Zusagen sie einhaelt. Gedacht zum Was in welcher Datei steckt und welche Zusagen sie einhält. Gedacht zum
Nachschlagen, nicht zum Durchlesen. Nachschlagen, nicht zum Durchlesen.
## Backend ## Backend
@@ -11,16 +11,16 @@ Baut die Express-App. Die **Reihenfolge** ist bedeutsam:
```mermaid ```mermaid
flowchart TD flowchart TD
J["express.json({ limit: '6mb' })<br/>Avatare kommen als Data-URL"] --> C["cors (nur fuer den Dev-Server)"] J["express.json({ limit: '6mb' })<br/>Avatare kommen als Data-URL"] --> C["cors (nur für den Dev-Server)"]
C --> H["/api/health"] C --> H["/api/health"]
H --> R["/api/auth, /users, /leads,<br/>/connections, /events, /opportunities"] H --> R["/api/auth, /users, /leads,<br/>/connections, /events, /opportunities"]
R --> S["/api/scanner (requireAuth + Router)"] R --> S["/api/scanner (requireAuth + Router)"]
S --> F["/api → 404 JSON<br/>faengt alles Unbekannte"] S --> F["/api → 404 JSON<br/>fängt alles Unbekannte"]
F --> E["Fehlerbehandlung:<br/>400 kaputtes JSON, 413 zu gross, 500 Rest"] F --> E["Fehlerbehandlung:<br/>400 kaputtes JSON, 413 zu groß, 500 Rest"]
``` ```
Beim Start: `initDatabase()` (CRM-Schema, Admin-Seed) → `initScannerDatabase()` Beim Start: `initDatabase()` (CRM-Schema, Admin-Seed) → `initScannerDatabase()`
(darf fehlschlagen, dann laeuft das CRM ohne Scanner) → Scheduler, sofern (darf fehlschlagen, dann läuft das CRM ohne Scanner) → Scheduler, sofern
`SCANNER_SCHEDULER !== 'false'`. `SCANNER_SCHEDULER !== 'false'`.
### `server/src/db.js` ### `server/src/db.js`
@@ -28,11 +28,11 @@ Beim Start: `initDatabase()` (CRM-Schema, Admin-Seed) → `initScannerDatabase()
| Export | Zweck | | Export | Zweck |
| --- | --- | | --- | --- |
| `pool` | mysql2-Pool, `connectionLimit: 8`, `dateStrings: true` | | `pool` | mysql2-Pool, `connectionLimit: 8`, `dateStrings: true` |
| `initDatabase()` | fuehrt `schema.sql` Statement fuer Statement aus, dann `ensureAdminUser()` | | `initDatabase()` | führt `schema.sql` Statement für Statement aus, dann `ensureAdminUser()` |
`dateStrings: true` ist eine bewusste Entscheidung: Termine kommen als `dateStrings: true` ist eine bewusste Entscheidung: Termine kommen als
`YYYY-MM-DD HH:MM:SS` **ohne** Zeitzonenumrechnung an. Der Kalender rechnet mit `YYYY-MM-DD HH:MM:SS` **ohne** Zeitzonenumrechnung an. Der Kalender rechnet mit
Wandzeit; ein `Date`-Objekt aus dem Treiber wuerde beim Serialisieren nach UTC Wandzeit; ein `Date`-Objekt aus dem Treiber würde beim Serialisieren nach UTC
verschieben. verschieben.
`ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne `ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne
@@ -44,19 +44,19 @@ im Quelltext steht kein Passwort.
| Export | Zweck | | Export | Zweck |
| --- | --- | | --- | --- |
| `signToken(user)` | JWT mit `sub` und `role`, Laufzeit aus `JWT_EXPIRES_IN` (12 h) | | `signToken(user)` | JWT mit `sub` und `role`, Laufzeit aus `JWT_EXPIRES_IN` (12 h) |
| `requireAuth` | Token pruefen **und** Benutzer frisch aus der DB laden | | `requireAuth` | Token prüfen **und** Benutzer frisch aus der DB laden |
| `requireAdmin` | 403, wenn `req.user.role !== 'admin'` | | `requireAdmin` | 403, wenn `req.user.role !== 'admin'` |
Dass `requireAuth` bei jeder Anfrage die Datenbank liest, ist Absicht: ein Dass `requireAuth` bei jeder Anfrage die Datenbank liest, ist Absicht: ein
deaktivierter oder geloeschter Benutzer ist sofort draussen und nicht erst, wenn deaktivierter oder gelöschter Benutzer ist sofort draußen und nicht erst, wenn
sein Token ablaeuft. `secret()` wirft, wenn `JWT_SECRET` fehlt oder kuerzer als 16 sein Token abläuft. `secret()` wirft, wenn `JWT_SECRET` fehlt oder kürzer als 16
Zeichen ist - lieber ein harter Startfehler als eine schwache Signatur. Zeichen ist - lieber ein harter Startfehler als eine schwache Signatur.
### `server/src/userFields.js` ### `server/src/userFields.js`
`normalizeColor` und `normalizeAvatar`, benutzt von `routes/auth.js` und `normalizeColor` und `normalizeAvatar`, benutzt von `routes/auth.js` und
`routes/users.js`. Farben nur als `#rrggbb`, Avatare nur als Bild-Data-URL oder `routes/users.js`. Farben nur als `#rrggbb`, Avatare nur als Bild-Data-URL oder
`http(s)`-Adresse, jeweils laengenbegrenzt. `http(s)`-Adresse, jeweils längenbegrenzt.
### Die CRM-Router ### Die CRM-Router
@@ -65,33 +65,33 @@ Alle unter `router.use(requireAuth)`.
| Router | Endpunkte | Rechte | | Router | Endpunkte | Rechte |
| --- | --- | --- | | --- | --- | --- |
| `auth.js` | `POST /login`, `GET /me`, `PATCH /me`, `POST /change-password` | `/login` offen, Rest angemeldet | | `auth.js` | `POST /login`, `GET /me`, `PATCH /me`, `POST /change-password` | `/login` offen, Rest angemeldet |
| `users.js` | `GET /directory`; danach `requireAdmin`: `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | `/directory` fuer alle, Rest Admin | | `users.js` | `GET /directory`; danach `requireAdmin`: `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | `/directory` für alle, Rest Admin |
| `leads.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`; Vorbereich: `GET /mail-pool/sources`, `POST /mail-pool/promote`, `POST /:id/promote` | jeder Angemeldete | | `leads.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`; Vorbereich: `GET /mail-pool/sources`, `POST /mail-pool/promote`, `POST /:id/promote` | jeder Angemeldete |
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete | | `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
| `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; aendern/loeschen nur Ersteller oder Admin | | `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin |
| `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | aendern/loeschen nur Ersteller, Zustaendiger oder Admin | | `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | ändern/löschen nur Ersteller, Zuständiger oder Admin |
| `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/candidates/:id/adopt`, `/candidates/:id/reject`; `DELETE /links/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen und zuordnen jeder; einrichten Admin | | `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/candidates/:id/adopt`, `/candidates/:id/reject`; `DELETE /links/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen und zuordnen jeder; einrichten Admin |
Wiederkehrende Muster in diesen Routern: Wiederkehrende Muster in diesen Routern:
* `emptyToNull()` - leere Zeichenketten werden `NULL`, damit „nicht gesetzt" und * `emptyToNull()` - leere Zeichenketten werden `NULL`, damit „nicht gesetzt" und
„leer" in der Datenbank nicht zwei Zustaende sind. „leer" in der Datenbank nicht zwei Zustände sind.
* `normalizeDate()` prueft echte Kalendertage; der 31. Februar wird abgelehnt und * `normalizeDate()` prüft echte Kalendertage; der 31. Februar wird abgelehnt und
nicht stillschweigend verschoben. nicht stillschweigend verschoben.
* `normalizeNumber()` akzeptiert Komma als Dezimaltrennzeichen und begrenzt nach * `normalizeNumber()` akzeptiert Komma als Dezimaltrennzeichen und begrenzt nach
oben - ein Tagessatz von 10 Millionen ist ein Tippfehler. oben - ein Tagessatz von 10 Millionen ist ein Tippfehler.
* `parseTags()` liest die als Text gespeicherten JSON-Arrays defensiv: kaputter * `parseTags()` liest die als Text gespeicherten JSON-Arrays defensiv: kaputter
Inhalt ergibt eine leere Liste, keinen Absturz. Inhalt ergibt eine leere Liste, keinen Absturz.
* `GET /login` antwortet bei unbekanntem Benutzer und falschem Passwort * `GET /login` antwortet bei unbekanntem Benutzer und falschem Passwort
**identisch**, damit sich ueber die API keine Benutzernamen aufzaehlen lassen. **identisch**, damit sich über die API keine Benutzernamen aufzählen lassen.
* Der letzte aktive Administrator kann nicht degradiert, deaktiviert oder * Der letzte aktive Administrator kann nicht degradiert, deaktiviert oder
geloescht werden. gelöscht werden.
### `server/src/settings.js` ### `server/src/settings.js`
`getSetting`, `getSettings`, `setSetting` auf `crm_settings`, dazu `asBool` und `getSetting`, `getSettings`, `setSetting` auf `crm_settings`, dazu `asBool` und
`asInt`. Alle Werte liegen als Text in der Datenbank. `getSettings(defaults)` `asInt`. Alle Werte liegen als Text in der Datenbank. `getSettings(defaults)`
nimmt die Vorgaben gleichzeitig als Positivliste der erlaubten Schluessel - ein nimmt die Vorgaben gleichzeitig als Positivliste der erlaubten Schlüssel - ein
unbekannter Name kommt so nie in die Datenbank. unbekannter Name kommt so nie in die Datenbank.
Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst
@@ -108,36 +108,36 @@ Postfachanbindung, fachlich in [11 Postfachanbindung](11-postfach.md).
| `html.js` | `htmlToText()` (Zeilen bleiben erhalten, Linkziele werden hinter den Linktext gesetzt), `sanitizeHtml()` | | `html.js` | `htmlToText()` (Zeilen bleiben erhalten, Linkziele werden hinter den Linktext gesetzt), `sanitizeHtml()` |
| `extract.js` | `extractCandidates()`, `parsePersonName()`, `parseSignature()`, `AUTO_ADOPT_MIN_CONFIDENCE` | | `extract.js` | `extractCandidates()`, `parsePersonName()`, `parseSignature()`, `AUTO_ADOPT_MIN_CONFIDENCE` |
| `store.js` | Zustandszeile, `upsertMessage()`, `insertCandidates()`, `linkMessage()`, `findLeadByEmail()` je Bestand | | `store.js` | Zustandszeile, `upsertMessage()`, `insertCandidates()`, `linkMessage()`, `findLeadByEmail()` je Bestand |
| `contacts.js` | `adoptCandidate()`: Eintrag in „Aus Mails" (`target: 'pool'`) oder richtiger Kontakt (`'contacts'`), sonst ergaenzen; `markCandidate()` | | `contacts.js` | `adoptCandidate()`: Eintrag in „Aus Mails" (`target: 'pool'`) oder richtiger Kontakt (`'contacts'`), sonst ergänzen; `markCandidate()` |
| `sync.js` | ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung | | `sync.js` | ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung |
| `scheduler.js` | Minutentakt, Faelligkeit aus `last_run_at`, kein Zeitfenster | | `scheduler.js` | Minutentakt, Fälligkeit aus `last_run_at`, kein Zeitfenster |
Eigenheiten, die man kennen muss: Eigenheiten, die man kennen muss:
* `graphRequest()` nimmt Pfade **und** vollstaendige URLs - Delta-Links kommen * `graphRequest()` nimmt Pfade **und** vollständige URLs - Delta-Links kommen
absolut von Graph zurueck. absolut von Graph zurück.
* Ein `401` verwirft das Token genau einmal und wiederholt; so ueberlebt ein Lauf * Ein `401` verwirft das Token genau einmal und wiederholt; so überlebt ein Lauf
ein serverseitig zurueckgezogenes Token. ein serverseitig zurückgezogenes Token.
* `upsertMessage()` laesst die Textspalten unberuehrt, wenn `bodyText` im * `upsertMessage()` lässt die Textspalten unberührt, wenn `bodyText` im
uebergebenen Objekt `undefined` ist. Ein reines Metadaten-Update darf den schon übergebenen Objekt `undefined` ist. Ein reines Metadaten-Update darf den schon
geholten Mailtext nicht loeschen. geholten Mailtext nicht löschen.
* `extractCandidates()` verlangt im Fliesstext eine Anrede vor dem Namen. * `extractCandidates()` verlangt im Fliesstext eine Anrede vor dem Namen.
Ohne diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener Ohne diese Bedingung liest der Musterabgleich jede Folge großgeschriebener
Woerter als Namen. Wörter als Namen.
* Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf * Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
kommt sofort mit `skipped` zurueck, statt parallel zu laufen. kommt sofort mit `skipped` zurück, statt parallel zu laufen.
### `server/src/routes/scanner.js` ### `server/src/routes/scanner.js`
Die portierte Scanner-API unter `/api/scanner`, vollstaendig in Die portierte Scanner-API unter `/api/scanner`, vollständig in
[03 Projekt Scanner](03-scanner.md#endpunkte) tabelliert. Eigenheiten: [03 Projekt Scanner](03-scanner.md#endpunkte) tabelliert. Eigenheiten:
* `multer` schreibt CV-PDFs nach `UPLOAD_DIR`, Grenze 20 MB. * `multer` schreibt CV-PDFs nach `UPLOAD_DIR`, Grenze 20 MB.
* `resolveCvPath()` findet eine Datei auch dann, wenn in der Datenbank noch ein * `resolveCvPath()` findet eine Datei auch dann, wenn in der Datenbank noch ein
relativer Pfad aus der alten App steht - es zaehlt der Dateiname. relativer Pfad aus der alten App steht - es zählt der Dateiname.
* `GET /agents` laesst `cvText` weg (mehrere Hundert KB pro Agent). * `GET /agents` lässt `cvText` weg (mehrere Hundert KB pro Agent).
* `SECRET_KEYS` (`openai_api_key`, `smtp_pass`) werden als `********` * `SECRET_KEYS` (`openai_api_key`, `smtp_pass`) werden als `********`
ausgeliefert; kommt dieser Wert oder ein leeres Feld zurueck, bleibt der ausgeliefert; kommt dieser Wert oder ein leeres Feld zurück, bleibt der
gespeicherte Wert stehen. gespeicherte Wert stehen.
* `normalizeCookie()` biegt Exporte von Cookie-Editor auf das zurecht, was * `normalizeCookie()` biegt Exporte von Cookie-Editor auf das zurecht, was
Playwright akzeptiert (`sameSite`, `expirationDate` → `expires`). Playwright akzeptiert (`sameSite`, `expirationDate` → `expires`).
@@ -147,27 +147,27 @@ Die portierte Scanner-API unter `/api/scanner`, vollstaendig in
| Datei | Inhalt | | Datei | Inhalt |
| --- | --- | | --- | --- |
| `db/config.js` | Sequelize-Instanz, eigene Datenbank, `underscored: true` | | `db/config.js` | Sequelize-Instanz, eigene Datenbank, `underscored: true` |
| `db/ensureDatabase.js` | `CREATE DATABASE IF NOT EXISTS`, Name gegen Zeichenklasse geprueft | | `db/ensureDatabase.js` | `CREATE DATABASE IF NOT EXISTS`, Name gegen Zeichenklasse geprüft |
| `models/index.js` | `initScannerDatabase()`: verbinden, `sync()`, Portale und Standardeinstellungen seeden | | `models/index.js` | `initScannerDatabase()`: verbinden, `sync()`, Portale und Standardeinstellungen seeden |
| `models/Agent.js` | `cvText`, `cvFilePath`, `keywords`, `excludeKeywords`, `isActive` | | `models/Agent.js` | `cvText`, `cvFilePath`, `keywords`, `excludeKeywords`, `isActive` |
| `models/Project.js` | Unique auf `(portal, portal_project_id)`, `updatedAt: false` | | `models/Project.js` | Unique auf `(portal, portal_project_id)`, `updatedAt: false` |
| `models/Match.js` | Unique auf `(agent_id, project_id)`, `pros`/`cons` als JSON-Text mit Getter/Setter | | `models/Match.js` | Unique auf `(agent_id, project_id)`, `pros`/`cons` als JSON-Text mit Getter/Setter |
| `models/Portal.js` | Zugangsdaten, `sessionState`, `connectionStatus` | | `models/Portal.js` | Zugangsdaten, `sessionState`, `connectionStatus` |
| `models/Setting.js` | Schluessel-Wert mit `getVal`/`setVal` | | `models/Setting.js` | Schlüssel-Wert mit `getVal`/`setVal` |
| `services/scraper.js` | Playwright je Portal; unveraendert aus der alten App | | `services/scraper.js` | Playwright je Portal; unverändert aus der alten App |
| `services/matchingEngine.js` | OpenAI `gpt-4o-mini`, `response_format: json_object`, `temperature: 0.1` | | `services/matchingEngine.js` | OpenAI `gpt-4o-mini`, `response_format: json_object`, `temperature: 0.1` |
| `services/pipeline.js` | ein Lauf: scrapen, speichern, bewerten, benachrichtigen | | `services/pipeline.js` | ein Lauf: scrapen, speichern, bewerten, benachrichtigen |
| `services/scheduler.js` | node-cron jede Minute, Zeitfenster, Sperrfrist, `last_scan_at` | | `services/scheduler.js` | node-cron jede Minute, Zeitfenster, Sperrfrist, `last_scan_at` |
| `services/cleanup.js` | Projekte aelter als 7 Tage loeschen (Treffer kaskadieren) | | `services/cleanup.js` | Projekte älter als 7 Tage löschen (Treffer kaskadieren) |
| `services/pdfParser.js` | `pdf-parse`, doppelte Leerzeilen raus | | `services/pdfParser.js` | `pdf-parse`, doppelte Leerzeilen raus |
| `utils/crypto.js` | AES ueber `crypto-js`; `decryptWith()` fuer fremde Schluessel | | `utils/crypto.js` | AES über `crypto-js`; `decryptWith()` für fremde Schlüssel |
| `uploadDir.js` | `UPLOAD_DIR`, `ensureUploadDir()`, `resolveCvPath()` | | `uploadDir.js` | `UPLOAD_DIR`, `ensureUploadDir()`, `resolveCvPath()` |
| `importLegacy.js` | einmalige Uebernahme aus der alten Datenbank | | `importLegacy.js` | einmalige Übernahme aus der alten Datenbank |
Zusagen, auf die sich der Rest verlaesst: Zusagen, auf die sich der Rest verlässt:
* `runMatchingPipeline()` laeuft **nie zweimal gleichzeitig** und gibt `false` * `runMatchingPipeline()` läuft **nie zweimal gleichzeitig** und gibt `false`
zurueck, wenn schon einer laeuft. `isPipelineRunning()` fragt den Zustand ab. zurück, wenn schon einer läuft. `isPipelineRunning()` fragt den Zustand ab.
* Der Zeitpunkt landet im `finally` in `settings.last_scan_at` - auch nach einem * Der Zeitpunkt landet im `finally` in `settings.last_scan_at` - auch nach einem
Abbruch, damit sich ein gescheiterter Lauf nicht sofort wiederholt. Abbruch, damit sich ein gescheiterter Lauf nicht sofort wiederholt.
* `cleanup.js` benutzt `sequelize.col('created_at')` statt des Attributnamens: * `cleanup.js` benutzt `sequelize.col('created_at')` statt des Attributnamens:
@@ -181,10 +181,10 @@ Zusagen, auf die sich der Rest verlaesst:
| --- | --- | | --- | --- |
| `apiRequest<T>(path, options)` | zentraler Fetch: Token, JSON-Header, 401-Behandlung, Fehlermeldung aus dem Body | | `apiRequest<T>(path, options)` | zentraler Fetch: Token, JSON-Header, 401-Behandlung, Fehlermeldung aus dem Body |
| `getToken` / `setToken` / `clearToken` | Token im `localStorage` (`perinco_crm_token`) | | `getToken` / `setToken` / `clearToken` | Token im `localStorage` (`perinco_crm_token`) |
| `setUnauthorizedHandler` | wird bei 401 gerufen; `AuthProvider` haengt sich hier ein | | `setUnauthorizedHandler` | wird bei 401 gerufen; `AuthProvider` hängt sich hier ein |
| `ApiError` | Fehler mit `status` | | `ApiError` | Fehler mit `status` |
| `authApi`, `usersApi`, `leadsApi`, `opportunitiesApi`, `eventsApi`, `connectionsApi` | typisierte Endpunkte | | `authApi`, `usersApi`, `leadsApi`, `opportunitiesApi`, `eventsApi`, `connectionsApi` | typisierte Endpunkte |
| `leadsApi.mailPoolSources` / `.promote` / `.promoteMany` | Vorbereich „Aus Mails": Herkunft lesen, einzeln bzw. gesammelt uebernehmen | | `leadsApi.mailPoolSources` / `.promote` / `.promoteMany` | Vorbereich „Aus Mails": Herkunft lesen, einzeln bzw. gesammelt übernehmen |
Zwei Feinheiten: Der `Content-Type` wird **nicht** gesetzt, wenn der Body Zwei Feinheiten: Der `Content-Type` wird **nicht** gesetzt, wenn der Body
`FormData` ist. Und ein Netzwerkfehler wird zu `ApiError(status 0)` mit `FormData` ist. Und ein Netzwerkfehler wird zu `ApiError(status 0)` mit
@@ -198,8 +198,8 @@ Typen (`ScannerAgent`, `ScannerPortal`, `ScannerProject`, `ScannerMatch`,
Die Feldnamen sind absichtlich die der Scanner-Datenbank (camelCase aus Die Feldnamen sind absichtlich die der Scanner-Datenbank (camelCase aus
Sequelize), inklusive der Grossbuchstaben `Agent` und `Project` an den Joins - Sequelize), inklusive der Grossbuchstaben `Agent` und `Project` an den Joins -
damit bleibt der Vergleich mit der alten App moeglich. Nur `ScannerProject` damit bleibt der Vergleich mit der alten App möglich. Nur `ScannerProject`
traegt beide Schreibweisen fuer `created_at`, weil das Modell die Spalte selbst trägt beide Schreibweisen für `created_at`, weil das Modell die Spalte selbst
umbenennt. umbenennt.
### `src/lib/mailApi.ts` ### `src/lib/mailApi.ts`
@@ -208,28 +208,28 @@ Typen (`MailMessageSummary`, `MailMessageDetail`, `MailCandidate`, `MailLink`,
`MailStatus`, `MailSyncResult`, `MailConnectionTest`, `MailSettingsValues`), das `MailStatus`, `MailSyncResult`, `MailConnectionTest`, `MailSettingsValues`), das
Objekt `mailApi`, `MAIL_SETTINGS_DEFAULTS` und die Datumshelfer. Objekt `mailApi`, `MAIL_SETTINGS_DEFAULTS` und die Datumshelfer.
`parseMailDate()` haengt ein `Z` an den MariaDB-Zeitstempel, bevor daraus ein `parseMailDate()` hängt ein `Z` an den MariaDB-Zeitstempel, bevor daraus ein
`Date` wird. Ohne das liest der Browser den Wert als Ortszeit, und die Anzeige `Date` wird. Ohne das liest der Browser den Wert als Ortszeit, und die Anzeige
waere je Zeitzone um Stunden verschoben - die Mailzeiten stehen als UTC in der wäre je Zeitzone um Stunden verschoben - die Mailzeiten stehen als UTC in der
Datenbank, anders als die Kalendertermine. `formatMailDate()` zeigt heute nur die Datenbank, anders als die Kalendertermine. `formatMailDate()` zeigt heute nur die
Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr. Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
### Kontexte ### Kontexte
| Datei | Haelt | Eigenheit | | Datei | Hält | Eigenheit |
| --- | --- | --- | | --- | --- | --- |
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prueft beim Start das gespeicherte Token gegen `/auth/me` | | `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prüft beim Start das gespeicherte Token gegen `/auth/me` |
| `theme.tsx` | hell/dunkel/System | Wahl in `localStorage`, Doppelklick loest sie wieder | | `theme.tsx` | hell/dunkel/System | Wahl in `localStorage`, Doppelklick löst sie wieder |
| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` oeffnet den Kontakt direkt | | `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` öffnet den Kontakt direkt |
| `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker fuer die ganze App | | `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker für die ganze App |
### Whisper ### Whisper
| Datei | Inhalt | | Datei | Inhalt |
| --- | --- | | --- | --- |
| `whisper.tsx` | Konstanten (`WHISPER_MODEL`, `WHISPER_DTYPE`, `WHISPER_LANGUAGE`), Provider, Fristen, Haenger-Erkennung | | `whisper.tsx` | Konstanten (`WHISPER_MODEL`, `WHISPER_DTYPE`, `WHISPER_LANGUAGE`), Provider, Fristen, Hänger-Erkennung |
| `whisper.worker.ts` | `WorkerRequest`/`WorkerResponse`, laedt und erkennt | | `whisper.worker.ts` | `WorkerRequest`/`WorkerResponse`, lädt und erkennt |
| `whisperModels.ts` | `fetchModelSize()` holt die echte Groesse von Hugging Face | | `whisperModels.ts` | `fetchModelSize()` holt die echte Größe von Hugging Face |
| `audio.ts` | `toWhisperAudio()` - Blob zu 16-kHz-Mono-PCM | | `audio.ts` | `toWhisperAudio()` - Blob zu 16-kHz-Mono-PCM |
| `dictation.tsx` | `useRecorder()` - Mikrofon, Dauer, Abbruch | | `dictation.tsx` | `useRecorder()` - Mikrofon, Dauer, Abbruch |
@@ -242,12 +242,12 @@ Warum genau diese Konstanten festgelegt sind, steht in
| --- | --- | | --- | --- |
| `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `StatusBadge`, `Avatar`, Statusfarben | | `components/leads/leadUi.tsx` | `inputClass`, `labelClass`, `StatusBadge`, `Avatar`, Statusfarben |
| `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs | | `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
| `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Uebernehmen, Sammeluebernahme | | `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme |
| `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergaenzen eines vorhandenen Kontakts | | `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergänzen eines vorhandenen Kontakts |
| `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` | | `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` |
| `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` | | `components/mail/mailUi.tsx` | `Badge`, `ConfidenceBar`, Herkunfts- und Statusbeschriftungen, `formatAddresses`, `avatarColor` |
| `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt | | `components/mail/MailBody.tsx` | Mailinhalt im `<iframe sandbox="">`, externe Bilder per CSP gesperrt |
| `components/ui/Modal.tsx` | `Modal` (Escape schliesst) und `ConfirmDialog` | | `components/ui/Modal.tsx` | `Modal` (Escape schließt) und `ConfirmDialog` |
| `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` | | `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` |
| `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat | | `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
| `lib/imageCompress.ts` | `compressImage()` auf 256 px, WebP → JPEG → PNG | | `lib/imageCompress.ts` | `compressImage()` auf 256 px, WebP → JPEG → PNG |
@@ -263,7 +263,7 @@ nicht auseinanderlaufen:
| `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter | | `MAIL_LEAD_SOURCE`, `LeadOriginFilter` | Herkunft „Mail" in `Lead.source` und ihr Filter |
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id | | `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id |
| `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Tagessatz x Personentage, gewichtet, formatiert | | `opportunityVolume()`, `weightedVolume()`, `formatEuro()` | Tagessatz x Personentage, gewichtet, formatiert |
| `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_STATUSES`, `CONNECTION_TYPES` | die Aufzaehlungen der Datenbank | | `OPPORTUNITY_STAGES`, `OPEN_STAGES`, `LEAD_STATUSES`, `CONNECTION_TYPES` | die Aufzählungen der Datenbank |
| `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik | | `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik |
## Datenfluss im Frontend ## Datenfluss im Frontend
@@ -278,16 +278,16 @@ flowchart TD
F -->|"still, ohne Ladeanzeige"| C F -->|"still, ohne Ladeanzeige"| C
G["Scanner-Ansichten"] -->|"eigener 30-s-Takt"| H["/api/scanner"] G["Scanner-Ansichten"] -->|"eigener 30-s-Takt"| H["/api/scanner"]
I["Mailansicht"] -->|"blaettert, sucht, filtert"| J["/api/mail"] I["Mailansicht"] -->|"blättert, sucht, filtert"| J["/api/mail"]
D --> I D --> I
I -->|"nach Uebernahme eines Vorschlags"| F I -->|"nach Übernahme eines Vorschlags"| F
``` ```
Die Mailansicht laedt ihre Mails selbst, bekommt Kontakte und Opportunities aber Die Mailansicht lädt ihre Mails selbst, bekommt Kontakte und Opportunities aber
als Props - der Zuordnungsdialog braucht beide Listen zum Vorschlagen. Nach einer als Props - der Zuordnungsdialog braucht beide Listen zum Vorschlagen. Nach einer
Kontaktuebernahme ruft sie `reload()`, damit der neue Kontakt in der Kontaktliste Kontaktübernahme ruft sie `reload()`, damit der neue Kontakt in der Kontaktliste
der App auftaucht. der App auftaucht.
Die fuenf CRM-Endpunkte laufen **parallel**, nicht nacheinander - sonst addieren Die fünf CRM-Endpunkte laufen **parallel**, nicht nacheinander - sonst addieren
sich fuenf Rundreisen beim Anmelden. Die Scanner-Ansichten haengen bewusst nicht sich fünf Rundreisen beim Anmelden. Die Scanner-Ansichten hängen bewusst nicht
an `loadData`: andere Datenbank, andere Auffrischung. an `loadData`: andere Datenbank, andere Auffrischung.
+34 -34
View File
@@ -1,64 +1,64 @@
# 10 Glossar # 10 Glossar
Begriffe, die in der Oberflaeche, im Code und in den Tabellen vorkommen. Die Begriffe, die in der Oberfläche, im Code und in den Tabellen vorkommen. Die
englischen Fachbegriffe sind bewusst nicht eingedeutscht - sie stehen so auch im englischen Fachbegriffe sind bewusst nicht eingedeutscht - sie stehen so auch im
Quelltext. Quelltext.
| Begriff | Bedeutung | | Begriff | Bedeutung |
| --- | --- | | --- | --- |
| **Agent** / Suchagent | Ein Beraterprofil im Scanner: Name, Lebenslauf als PDF, Suchbegriffe, Ausschluss-Begriffe. Tabelle `agents`. Nicht zu verwechseln mit einem Benutzerkonto. | | **Agent** / Suchagent | Ein Beraterprofil im Scanner: Name, Lebenslauf als PDF, Suchbegriffe, Ausschluss-Begriffe. Tabelle `agents`. Nicht zu verwechseln mit einem Benutzerkonto. |
| **Ausschluss-Begriff** | Wort, das ein Projekt fuer diesen Agenten sofort aussortiert - noch **vor** der KI-Bewertung, spart also Kosten. Typisch: „Arbeitnehmerueberlassung". | | **Ausschluss-Begriff** | Wort, das ein Projekt für diesen Agenten sofort aussortiert - noch **vor** der KI-Bewertung, spart also Kosten. Typisch: „Arbeitnehmerüberlassung". |
| **Benutzer** | Konto fuer die Anmeldung, Rolle `admin` oder `user`. Tabelle `crm_users`. | | **Benutzer** | Konto für die Anmeldung, Rolle `admin` oder `user`. Tabelle `crm_users`. |
| **Boerse** / Portal | Projektplattform, die der Scanner durchsucht. Oeffentlich (Hays, Etengo) oder geschuetzt (Freelance.de, Freelancermap.de). Tabelle `portals`. | | **Börse** / Portal | Projektplattform, die der Scanner durchsucht. Öffentlich (Hays, Etengo) oder geschützt (Freelance.de, Freelancermap.de). Tabelle `portals`. |
| **App-only** | Zugriff auf Microsoft 365 im Namen der Anwendung, nicht eines Benutzers (OAuth-Ablauf *Client Credentials*). Braucht Anwendungsberechtigungen und Administratorzustimmung, aber keinen Login. | | **App-only** | Zugriff auf Microsoft 365 im Namen der Anwendung, nicht eines Benutzers (OAuth-Ablauf *Client Credentials*). Braucht Anwendungsberechtigungen und Administratorzustimmung, aber keinen Login. |
| **Cookie-Import** | Uebertragen einer angemeldeten Browsersitzung auf den Scraper, per JSON-Export der Erweiterung *Cookie-Editor*. Landet als `session_state` am Portal. | | **Cookie-Import** | Übertragen einer angemeldeten Browsersitzung auf den Scraper, per JSON-Export der Erweiterung *Cookie-Editor*. Landet als `session_state` am Portal. |
| **Delta Query** | Graph-Abfrage, die beim naechsten Aufruf nur noch die Aenderungen liefert. Der Fortschritt steckt in einem Verweis (`delta_link`), der in `crm_mail_sync_state` liegt. | | **Delta Query** | Graph-Abfrage, die beim nächsten Aufruf nur noch die Änderungen liefert. Der Fortschritt steckt in einem Verweis (`delta_link`), der in `crm_mail_sync_state` liegt. |
| **Kontakt** / Lead | Person im Netzwerk: Firma, Position, Status, Verbindungen. Tabelle `crm_leads` mit `mail_pool = 0`. Im Code durchgaengig `lead`. | | **Kontakt** / Lead | Person im Netzwerk: Firma, Position, Status, Verbindungen. Tabelle `crm_leads` mit `mail_pool = 0`. Im Code durchgängig `lead`. |
| **Aus Mails** / Vorbereich | Zeilen in `crm_leads` mit `mail_pool = 1`: Personen, die der Postfachabgleich gefunden hat. Eigener Menuepunkt unter *Kontakte*, taucht in keiner Auswahlliste auf. Erst das Uebernehmen macht daraus einen Kontakt. | | **Aus Mails** / Vorbereich | Zeilen in `crm_leads` mit `mail_pool = 1`: Personen, die der Postfachabgleich gefunden hat. Eigener Menüpunkt unter *Kontakte*, taucht in keiner Auswahlliste auf. Erst das Übernehmen macht daraus einen Kontakt. |
| **Dublette** (Aus Mails) | Ein Fund, zu dem es in den Kontakten schon eine Person gibt - erkannt an der E-Mail-Adresse, sonst am vollen Namen. In der Liste **gold** markiert, abweichende Felder hervorgehoben. | | **Dublette** (Aus Mails) | Ein Fund, zu dem es in den Kontakten schon eine Person gibt - erkannt an der E-Mail-Adresse, sonst am vollen Namen. In der Liste **gold** markiert, abweichende Felder hervorgehoben. |
| **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird uebernommen, ergaenzt oder verworfen. | | **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird übernommen, ergänzt oder verworfen. |
| **Match** / Treffer | Die Bewertung **eines** Projekts fuer **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. | | **Match** / Treffer | Die Bewertung **eines** Projekts für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. |
| **Opportunity** | Geschaeft in Arbeit: Phase, Tagessatz, Laufzeit, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. | | **Opportunity** | Geschäft in Arbeit: Phase, Tagessatz, Laufzeit, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. |
| **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. | | **Phase** / Stage | Fortschritt einer Opportunity: Neu, Qualifizierung, Angebot, Verhandlung, Gewonnen, Verloren. |
| **Pipeline** | Zwei Bedeutungen, je nach Zusammenhang: die Kanban-Ansicht der Opportunities **oder** der Suchlauf des Scanners (`services/pipeline.js`). | | **Pipeline** | Zwei Bedeutungen, je nach Zusammenhang: die Kanban-Ansicht der Opportunities **oder** der Suchlauf des Scanners (`services/pipeline.js`). |
| **Projekt** | Ein von einer Boerse gescrapter Auftrag. Tabelle `projects`, Aufbewahrung 7 Tage. | | **Projekt** | Ein von einer Börse gescrapter Auftrag. Tabelle `projects`, Aufbewahrung 7 Tage. |
| **Scraper** | Der Teil, der mit einem echten Browser (Playwright/Chromium) die Suchseiten der Boersen abruft. | | **Scraper** | Der Teil, der mit einem echten Browser (Playwright/Chromium) die Suchseiten der Börsen abruft. |
| **Session** (Portal) | Gespeicherte Cookies einer angemeldeten Boersen-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 gueltig. | | **Sitzung** (CRM) | Die eigene Anmeldung, als JWT im `localStorage`, 12 Stunden gültig. |
| **Suchintervall** | Abstand zwischen zwei automatischen Suchlaeufen, 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. |
| **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 Staerke. Tabelle `crm_lead_connections`. | | **Verbindung** | Kante zwischen zwei Kontakten im Netzwerkdiagramm, mit Art und Stärke. Tabelle `crm_lead_connections`. |
| **Volumen** | Tagessatz x Personentage. **Gewichtet** heisst zusaetzlich mit der Abschlusswahrscheinlichkeit multipliziert. | | **Volumen** | Tagessatz x Personentage. **Gewichtet** heißt zusätzlich mit der Abschlusswahrscheinlichkeit multipliziert. |
| **Whisper** | Das Spracherkennungsmodell fuer das Diktat. Laeuft im Browser, nicht auf dem Server. | | **Whisper** | Das Spracherkennungsmodell für das Diktat. Läuft im Browser, nicht auf dem Server. |
| **Zugriffsrichtlinie** | `ApplicationAccessPolicy` in Exchange Online. Begrenzt eine App-only-Berechtigung auf einzelne Postfaecher - ohne sie gilt sie tenant-weit. | | **Zugriffsrichtlinie** | `ApplicationAccessPolicy` in Exchange Online. Begrenzt eine App-only-Berechtigung auf einzelne Postfächer - ohne sie gilt sie tenant-weit. |
| **Zustaendiger** / Owner | Benutzer, dem ein Kontakt oder eine Opportunity zugeordnet ist. Darf sie aendern, auch wenn er sie nicht angelegt hat. | | **Zuständiger** / Owner | Benutzer, dem ein Kontakt oder eine Opportunity zugeordnet ist. Darf sie ändern, auch wenn er sie nicht angelegt hat. |
| **Zuversicht** / Confidence | Wert von 0 bis 100 an einem Kontakt-Vorschlag. Ab 70 und mit echter Mailadresse landet er automatisch im Vorbereich *Aus Mails*. | | **Zuversicht** / Confidence | Wert von 0 bis 100 an einem Kontakt-Vorschlag. Ab 70 und mit echter Mailadresse landet er automatisch im Vorbereich *Aus Mails*. |
## Herkunft eines Kontakt-Vorschlags ## Herkunft eines Kontakt-Vorschlags
| Wert in der DB | in der Oberflaeche | Zuversicht | | Wert in der DB | in der Oberfläche | Zuversicht |
| --- | --- | --- | | --- | --- | --- |
| `absender` | Absender | 90, mit bestaetigender Signatur 95 | | `absender` | Absender | 90, mit bestätigender Signatur 95 |
| `empfaenger` | Empfaenger | 78 | | `empfaenger` | Empfänger | 78 |
| `signatur` | Signatur | 55 - weitere Adresse in der Signatur, etwa eine Assistenz | | `signatur` | Signatur | 55 - weitere Adresse in der Signatur, etwa eine Assistenz |
| `text` | Im Text genannt | 35 - nur mit Anrede und Rufnummer, nie automatisch uebernommen | | `text` | Im Text genannt | 35 - nur mit Anrede und Rufnummer, nie automatisch übernommen |
## Status eines Kontakt-Vorschlags ## Status eines Kontakt-Vorschlags
| Wert | in der Oberflaeche | Bedeutung | | Wert | in der Oberfläche | Bedeutung |
| --- | --- | --- | | --- | --- | --- |
| `offen` | Offen | wartet auf eine Entscheidung | | `offen` | Offen | wartet auf eine Entscheidung |
| `uebernommen` | Uebernommen | es entstand ein Eintrag - im Automatiklauf in *Aus Mails*, von Hand ein Kontakt | | `uebernommen` | Übernommen | es entstand ein Eintrag - im Automatiklauf in *Aus Mails*, von Hand ein Kontakt |
| `dublette` | Ergaenzt | die Adresse war im Zielbestand vorhanden, der Eintrag wurde erweitert | | `dublette` | Ergänzt | die Adresse war im Zielbestand vorhanden, der Eintrag wurde erweitert |
| `verworfen` | Verworfen | von Hand abgelehnt | | `verworfen` | Verworfen | von Hand abgelehnt |
## Statuswerte im Scanner ## Statuswerte im Scanner
| Wert in der DB | in der Oberflaeche | Bedeutung | | Wert in der DB | in der Oberfläche | Bedeutung |
| --- | --- | --- | | --- | --- | --- |
| `new` | Neu | noch nicht angesehen | | `new` | Neu | noch nicht angesehen |
| `reviewed` | Gesehen | geprueft, noch keine Bewerbung | | `reviewed` | Gesehen | geprüft, noch keine Bewerbung |
| `applied` | Beworben | Bewerbung ist raus | | `applied` | Beworben | Bewerbung ist raus |
| `rejected` | Abgelehnt | passt nicht; erscheint nur unter *Unpassend* | | `rejected` | Abgelehnt | passt nicht; erscheint nur unter *Unpassend* |
@@ -66,7 +66,7 @@ Quelltext.
| Wert | Bedeutung | | Wert | Bedeutung |
| --- | --- | | --- | --- |
| `connected` | Verbindung steht, letzte Pruefung erfolgreich | | `connected` | Verbindung steht, letzte Prüfung erfolgreich |
| `expired` | Session abgelaufen oder CAPTCHA - neuer Cookie-Import faellig | | `expired` | Session abgelaufen oder CAPTCHA - neuer Cookie-Import fällig |
| `error` | Portal nicht erreichbar oder Seitenaufbau geaendert | | `error` | Portal nicht erreichbar oder Seitenaufbau geändert |
| `disconnected` | nie verbunden; bei oeffentlichen Portalen heisst das nur „bereit" | | `disconnected` | nie verbunden; bei öffentlichen Portalen heißt das nur „bereit" |
+93 -93
View File
@@ -1,15 +1,15 @@
# 11 Postfachanbindung (Microsoft 365) # 11 Postfachanbindung (Microsoft 365)
Das gemeinsame Vertriebspostfach `crm@perinco.de` wird **lesend** in das CRM Das gemeinsame Vertriebspostfach `crm@perinco.de` wird **lesend** in das CRM
gespiegelt. Aus jeder Mail entstehen Kontakt-Vorschlaege, und jede Mail laesst gespiegelt. Aus jeder Mail entstehen Kontakt-Vorschläge, und jede Mail lässt
sich an eine Opportunity und an Kontakte haengen. sich an eine Opportunity und an Kontakte hängen.
Vorbild ist die gleiche Funktion im Altsystem KeepTrack. Uebernommen wurde die Vorbild ist die gleiche Funktion im Altsystem KeepTrack. Übernommen wurde die
Idee, nicht die Umsetzung - die Unterschiede stehen unten unter Idee, nicht die Umsetzung - die Unterschiede stehen unten unter
[Was anders ist als im Altsystem](#was-anders-ist-als-im-altsystem) und sind der [Was anders ist als im Altsystem](#was-anders-ist-als-im-altsystem) und sind der
Grund fuer mehrere Entwurfsentscheidungen. Grund für mehrere Entwurfsentscheidungen.
## Ueberblick ## Überblick
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -25,7 +25,7 @@ flowchart LR
SY["mail/sync.js<br/>Delta Query"] SY["mail/sync.js<br/>Delta Query"]
EX["mail/extract.js<br/>Kontakte lesen"] EX["mail/extract.js<br/>Kontakte lesen"]
ST["mail/store.js"] ST["mail/store.js"]
CO["mail/contacts.js<br/>Uebernahme"] CO["mail/contacts.js<br/>Übernahme"]
API["routes/mail.js<br/>/api/mail"] API["routes/mail.js<br/>/api/mail"]
end end
@@ -57,7 +57,7 @@ Login.
2. **Client-Geheimnis** anlegen und den Wert sofort kopieren. 2. **Client-Geheimnis** anlegen und den Wert sofort kopieren.
3. **API-Berechtigung** Microsoft Graph &rarr; *Anwendungsberechtigungen* &rarr; 3. **API-Berechtigung** Microsoft Graph &rarr; *Anwendungsberechtigungen* &rarr;
`Mail.Read`, danach Administratorzustimmung erteilen. `Mail.Read`, danach Administratorzustimmung erteilen.
`Mail.ReadBasic` genuegt nicht, es liefert keinen Mailtext. `Mail.ReadBasic` genügt nicht, es liefert keinen Mailtext.
4. **Zugriff auf dieses eine Postfach begrenzen.** Anwendungsberechtigungen 4. **Zugriff auf dieses eine Postfach begrenzen.** Anwendungsberechtigungen
gelten tenant-weit; ohne Richtlinie darf die App jedes Postfach lesen: gelten tenant-weit; ohne Richtlinie darf die App jedes Postfach lesen:
@@ -73,8 +73,8 @@ Login.
Test-ApplicationAccessPolicy -Identity sascha.wolf@perinco.de -AppId <APP-ID> # Denied Test-ApplicationAccessPolicy -Identity sascha.wolf@perinco.de -AppId <APP-ID> # Denied
``` ```
`Add-MailboxPermission` ist hier **wirkungslos** - das ist der Weg fuer `Add-MailboxPermission` ist hier **wirkungslos** - das ist der Weg für
delegierten Zugriff bzw. EWS-Impersonation. Bei App-only zaehlt allein die delegierten Zugriff bzw. EWS-Impersonation. Bei App-only zählt allein die
Zugriffsrichtlinie. Zugriffsrichtlinie.
Der Server muss ausgehend `login.microsoftonline.com` und `graph.microsoft.com` Der Server muss ausgehend `login.microsoftonline.com` und `graph.microsoft.com`
@@ -82,8 +82,8 @@ auf 443 erreichen.
## Umgebungsvariablen ## Umgebungsvariablen
Zugangsdaten stehen ausschliesslich in `server/.env` - nie in der Datenbank und Zugangsdaten stehen ausschließlich in `server/.env` - nie in der Datenbank und
nie in einer API-Antwort. Ein Datenbank-Dump enthaelt damit keine Geheimnisse. nie in einer API-Antwort. Ein Datenbank-Dump enthält damit keine Geheimnisse.
| Variable | Pflicht | Bedeutung | | Variable | Pflicht | Bedeutung |
| --- | --- | --- | | --- | --- | --- |
@@ -143,32 +143,32 @@ sequenceDiagram
Wesentliche Eigenschaften: Wesentliche Eigenschaften:
* **Kein Schreibzugriff.** Der Fortschritt steht im `delta_link` in der * **Kein Schreibzugriff.** Der Fortschritt steht im `delta_link` in der
Datenbank, nicht im `isRead`-Kennzeichen des Postfachs. Deshalb genuegt Datenbank, nicht im `isRead`-Kennzeichen des Postfachs. Deshalb genügt
`Mail.Read`, und deshalb geht keine Mail verloren, wenn ein Mensch sie vorher `Mail.Read`, und deshalb geht keine Mail verloren, wenn ein Mensch sie vorher
in Outlook oeffnet. in Outlook öffnet.
* **Fortsetzbar.** Nach jeder Seite wird der Verweis gesichert. Ein Absturz oder * **Fortsetzbar.** Nach jeder Seite wird der Verweis gesichert. Ein Absturz oder
ein Deployment mitten im Erstabgleich kostet hoechstens eine Seite. ein Deployment mitten im Erstabgleich kostet höchstens eine Seite.
* **Gedeckelt.** `MAIL_MAX_PAGES` begrenzt einen Lauf. Ist danach noch etwas * **Gedeckelt.** `MAIL_MAX_PAGES` begrenzt einen Lauf. Ist danach noch etwas
offen, setzt der naechste Lauf nach 15 Sekunden fort (`has_more`), statt das offen, setzt der nächste Lauf nach 15 Sekunden fort (`has_more`), statt das
normale Intervall abzuwarten. normale Intervall abzuwarten.
* **Selbstheilend.** Antwortet Graph mit `410 Gone` bzw. `resyncRequired`, wird * **Selbstheilend.** Antwortet Graph mit `410 Gone` bzw. `resyncRequired`, wird
der Verweis verworfen und beim naechsten Lauf neu aufgebaut. Vorhandene Mails der Verweis verworfen und beim nächsten Lauf neu aufgebaut. Vorhandene Mails
werden am SHA-256 der Graph-ID wiedererkannt, es entstehen keine Doppelten. werden am SHA-256 der Graph-ID wiedererkannt, es entstehen keine Doppelten.
* **Nur Metadaten in der Delta-Abfrage.** Der Mailtext wird einzeln nachgeholt * **Nur Metadaten in der Delta-Abfrage.** Der Mailtext wird einzeln nachgeholt
und nur, wenn er fehlt. Ein reines Metadaten-Update (etwa ein geaendertes und nur, wenn er fehlt. Ein reines Metadaten-Update (etwa ein geändertes
Gelesen-Kennzeichen) loescht den gespeicherten Text nicht. Gelesen-Kennzeichen) löscht den gespeicherten Text nicht.
* **Zeitgrenze doppelt.** Der Erstabgleich versucht * **Zeitgrenze doppelt.** Der Erstabgleich versucht
`$filter=receivedDateTime ge ...`; die Filterunterstuetzung in Delta-Abfragen `$filter=receivedDateTime ge ...`; die Filterunterstützung in Delta-Abfragen
ist eng begrenzt, bei einem 400 laeuft er ohne Filter weiter. Der Filter ist ist eng begrenzt, bei einem 400 läuft er ohne Filter weiter. Der Filter ist
deshalb nur eine Ersparnis - geprueft wird die Grenze **immer** beim Speichern, deshalb nur eine Ersparnis - geprüft wird die Grenze **immer** beim Speichern,
und zwar nur fuer noch unbekannte Mails. Ein an den ersten Lauf gebundener und zwar nur für noch unbekannte Mails. Ein an den ersten Lauf gebundener
Merker waere hier falsch: ein ungefilterter Erstabgleich zieht sich ueber Merker wäre hier falsch: ein ungefilterter Erstabgleich zieht sich über
mehrere Laeufe, und in den folgenden kaeme der Rest des Postfachs trotz Grenze mehrere Läufe, und in den folgenden käme der Rest des Postfachs trotz Grenze
herein. herein.
Im Postfach geloeschte Mails werden mit `is_deleted = 1` gekennzeichnet, nicht Im Postfach gelöschte Mails werden mit `is_deleted = 1` gekennzeichnet, nicht
entfernt: eine an eine Opportunity gehaengte Mail soll nicht aus dem CRM entfernt: eine an eine Opportunity gehängte Mail soll nicht aus dem CRM
verschwinden, weil jemand sein Postfach aufraeumt. verschwinden, weil jemand sein Postfach aufräumt.
## Kontakte aus Mails ## Kontakte aus Mails
@@ -178,7 +178,7 @@ nicht.
```mermaid ```mermaid
flowchart TD flowchart TD
M["Mail"] --> A["Absender<br/>from (Graph)"] M["Mail"] --> A["Absender<br/>from (Graph)"]
M --> E["Empfaenger<br/>to, cc, bcc (Graph)"] M --> E["Empfänger<br/>to, cc, bcc (Graph)"]
M --> S["Signaturblock<br/>Text nach der Grussformel"] M --> S["Signaturblock<br/>Text nach der Grussformel"]
M --> T["Fliesstext<br/>Anrede + Name + Rufnummer"] M --> T["Fliesstext<br/>Anrede + Name + Rufnummer"]
@@ -193,26 +193,26 @@ flowchart TD
C --> Z{"E-Mail vorhanden<br/>und Zuversicht &ge; 70?"} C --> Z{"E-Mail vorhanden<br/>und Zuversicht &ge; 70?"}
Z -->|ja, Automatik an| N["Eintrag in 'Aus Mails'<br/>mail_pool = 1"] Z -->|ja, Automatik an| N["Eintrag in 'Aus Mails'<br/>mail_pool = 1"]
Z -->|"Adresse dort schon"| G["Eintrag ergaenzen"] Z -->|"Adresse dort schon"| G["Eintrag ergänzen"]
Z -->|nein| R["bleibt zur Durchsicht"] Z -->|nein| R["bleibt zur Durchsicht"]
``` ```
Zuversicht je Herkunft: Absender 90 (95, wenn die Signatur die Adresse Zuversicht je Herkunft: Absender 90 (95, wenn die Signatur die Adresse
bestaetigt), Empfaenger 78, weitere Adresse aus der Signatur 55, im Text genannte bestätigt), Empfänger 78, weitere Adresse aus der Signatur 55, im Text genannte
Person 35. Person 35.
Aus dem Signaturblock kommen Telefon und Mobil (nach Beschriftung Aus dem Signaturblock kommen Telefon und Mobil (nach Beschriftung
unterschieden), Firma (an der Rechtsform erkannt), Position, Strasse, PLZ, Ort, unterschieden), Firma (an der Rechtsform erkannt), Position, Straße, PLZ, Ort,
Website und LinkedIn-Profil. Website und LinkedIn-Profil.
Zwei Entscheidungen, die die Trefferqualitaet tragen: Zwei Entscheidungen, die die Trefferqualität tragen:
* **Im Fliesstext ist eine Anrede Pflicht** („Herrn Max Mustermann Tel. …"). * **Im Fliesstext ist eine Anrede Pflicht** („Herrn Max Mustermann Tel. …").
Ohne diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener Ohne diese Bedingung liest der Musterabgleich jede Folge großgeschriebener
Woerter als Namen; „Zweiter Ansprechpartner" landete so als Kontakt. Ein Wörter als Namen; „Zweiter Ansprechpartner" landete so als Kontakt. Ein
verpasster Treffer ist harmloser als ein falscher - eine Durchsichtsliste voll verpasster Treffer ist harmloser als ein falscher - eine Durchsichtsliste voll
Unsinn schaut niemand an. Unsinn schaut niemand an.
* **Der Anzeigename schlaegt die Adresse.** Bei „Mueller" plus * **Der Anzeigename schlägt die Adresse.** Bei „Müller" plus
`k.mueller@…` wird der Vorname aus der Adresse genommen, der Nachname aber aus `k.mueller@…` wird der Vorname aus der Adresse genommen, der Nachname aber aus
dem Anzeigenamen - dort stehen die Umlaute richtig. dem Anzeigenamen - dort stehen die Umlaute richtig.
@@ -221,64 +221,64 @@ Zwei Entscheidungen, die die Trefferqualitaet tragen:
Der Hintergrundlauf schreibt **nicht** in den Kontaktbestand. Was er findet, Der Hintergrundlauf schreibt **nicht** in den Kontaktbestand. Was er findet,
landet in einem Vorbereich: `crm_leads.mail_pool = 1`. Die Kontaktansicht zeigt landet in einem Vorbereich: `crm_leads.mail_pool = 1`. Die Kontaktansicht zeigt
nur `mail_pool = 0`, der Vorbereich steht als Unterpunkt *Aus Mails* unter dem nur `mail_pool = 0`, der Vorbereich steht als Unterpunkt *Aus Mails* unter dem
Menuepunkt *Kontakte*. Menüpunkt *Kontakte*.
Der Grund ist Pflegbarkeit: die Kontaktliste ist eine gepflegte Liste, an der Der Grund ist Pflegbarkeit: die Kontaktliste ist eine gepflegte Liste, an der
Opportunities, Kalender und Netzwerk haengen. Ein Abgleich, der ungefragt jede Opportunities, Kalender und Netzwerk hängen. Ein Abgleich, der ungefragt jede
Adresse aus jeder Mail hineinschreibt, macht sie unbrauchbar - genau daran ist Adresse aus jeder Mail hineinschreibt, macht sie unbrauchbar - genau daran ist
das Altsystem gescheitert. Deshalb sind Funde aus Mails Rohmaterial, bis jemand das Altsystem gescheitert. Deshalb sind Funde aus Mails Rohmaterial, bis jemand
entscheidet. entscheidet.
Eine Spalte statt einer zweiten Tabelle, weil `crm_mail_links` und Eine Spalte statt einer zweiten Tabelle, weil `crm_mail_links` und
`crm_mail_contact_candidates` per Fremdschluessel auf `crm_leads` zeigen: der `crm_mail_contact_candidates` per Fremdschlüssel auf `crm_leads` zeigen: der
Vorbereich behaelt damit seine Mailbezuege, ohne dass es zwei Wege gibt, eine Vorbereich behält damit seine Mailbezüge, ohne dass es zwei Wege gibt, eine
Person mit einer Mail zu verbinden. Person mit einer Mail zu verbinden.
```mermaid ```mermaid
flowchart LR flowchart LR
P["Aus Mails<br/>mail_pool = 1"] --> D{"Person schon<br/>in den Kontakten?"} P["Aus Mails<br/>mail_pool = 1"] --> D{"Person schon<br/>in den Kontakten?"}
D -->|nein| NEU["In Kontakte uebernehmen<br/>Kopie mit mail_pool = 0"] D -->|nein| NEU["In Kontakte übernehmen<br/>Kopie mit mail_pool = 0"]
D -->|"ja - Adresse oder Name"| GOLD["gold markiert<br/>Abweichungen hervorgehoben"] D -->|"ja - Adresse oder Name"| GOLD["gold markiert<br/>Abweichungen hervorgehoben"]
GOLD --> M["Felder einzeln in den<br/>vorhandenen Kontakt uebernehmen"] GOLD --> M["Felder einzeln in den<br/>vorhandenen Kontakt übernehmen"]
GOLD --> N2["trotzdem neu anlegen"] GOLD --> N2["trotzdem neu anlegen"]
P --> W["entfernen"] P --> W["entfernen"]
``` ```
Der Abgleich gegen die Kontakte laeuft im Browser (`mailPool.ts`): erst ueber die Der Abgleich gegen die Kontakte läuft im Browser (`mailPool.ts`): erst über die
E-Mail-Adresse, sonst ueber den vollen Namen. Telefonnummern werden dabei ueber E-Mail-Adresse, sonst über den vollen Namen. Telefonnummern werden dabei über
ihre Ziffern verglichen, damit „0221 123-4" und „02211234" nicht als Abweichung ihre Ziffern verglichen, damit „0221 123-4" und „02211234" nicht als Abweichung
erscheinen. erscheinen.
Beim Uebernehmen sind nur die **Luecken** vorausgewaehlt - Felder, in denen der Beim Übernehmen sind nur die **Lücken** vorausgewählt - Felder, in denen der
Kontakt noch nichts hat. Werte, die einen gepflegten Eintrag ersetzen wuerden, Kontakt noch nichts hat. Werte, die einen gepflegten Eintrag ersetzen würden,
sind als *ueberschreibt* gekennzeichnet und muessen bewusst angehakt werden: der sind als *überschreibt* gekennzeichnet und müssen bewusst angehakt werden: der
Mailfund kann aus einer alten Signatur stammen. Mailfund kann aus einer alten Signatur stammen.
Uebernommene Kontakte tragen weiterhin `crm_leads.source = 'Mail'` - die Übernommene Kontakte tragen weiterhin `crm_leads.source = 'Mail'` - die
Kontaktliste hat dafuer den Herkunftsfilter *Aus Mails uebernommen*. Kontaktliste hat dafür den Herkunftsfilter *Aus Mails übernommen*.
Bestandsumstellung: `db.js` verschiebt beim ersten Start alle vorhandenen Bestandsumstellung: `db.js` verschiebt beim ersten Start alle vorhandenen
Kontakte mit `source = 'Mail'` in den Vorbereich. Das laeuft genau einmal, Kontakte mit `source = 'Mail'` in den Vorbereich. Das läuft genau einmal,
festgehalten im Merker `leads_mail_pool_migrated` in `crm_settings` - sonst festgehalten im Merker `leads_mail_pool_migrated` in `crm_settings` - sonst
wanderten uebernommene Kontakte bei jedem Neustart zurueck. wanderten übernommene Kontakte bei jedem Neustart zurück.
## Anzeige des Mailtexts ## Anzeige des Mailtexts
Fremdes HTML laeuft in einem `<iframe sandbox="">` **ohne jedes allow-Flag**. Das Fremdes HTML läuft in einem `<iframe sandbox="">` **ohne jedes allow-Flag**. Das
Dokument bekommt eine eigene, undurchsichtige Origin und kann weder Skripte Dokument bekommt eine eigene, undurchsichtige Origin und kann weder Skripte
ausfuehren noch auf die Anwendung zugreifen. ausführen noch auf die Anwendung zugreifen.
`allow-same-origin` waere hier ein Fehler: ein `srcDoc`-Dokument erbt damit die `allow-same-origin` wäre hier ein Fehler: ein `srcDoc`-Dokument erbt damit die
Origin der Anwendung und koennte `localStorage` lesen - also das Anmeldetoken. Origin der Anwendung und könnte `localStorage` lesen - also das Anmeldetoken.
Weil die Hoehe des Inhalts ohne dieses Flag nicht auslesbar ist, hat der Rahmen Weil die Höhe des Inhalts ohne dieses Flag nicht auslesbar ist, hat der Rahmen
eine feste Hoehe mit eigenem Rollbalken und einen Schalter „Groesser". eine feste Höhe mit eigenem Rollbalken und einen Schalter „Größer".
Zusaetzlich entfernt das Backend beim Speichern Skripte, eingebettete Rahmen, Zusätzlich entfernt das Backend beim Speichern Skripte, eingebettete Rahmen,
Formulare, Ereignis-Attribute und gefaehrliche URL-Schemata Formulare, Ereignis-Attribute und gefährliche URL-Schemata
(`mail/html.js`, `sanitizeHtml`). (`mail/html.js`, `sanitizeHtml`).
Externe Bilder sind per Content-Security-Policy im eingebetteten Dokument Externe Bilder sind per Content-Security-Policy im eingebetteten Dokument
gesperrt und lassen sich pro Mail freigeben - andernfalls meldet jedes Oeffnen gesperrt und lassen sich pro Mail freigeben - andernfalls meldet jedes Öffnen
dem Absender, dass die Mail gelesen wurde. dem Absender, dass die Mail gelesen wurde.
## Endpunkte ## Endpunkte
@@ -287,79 +287,79 @@ Alle unter `/api/mail`, alle hinter `requireAuth`.
| Methode | Pfad | Rolle | Zweck | | Methode | Pfad | Rolle | Zweck |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| GET | `/status` | alle | Betriebszustand, Zaehler, letzter Fehler | | GET | `/status` | alle | Betriebszustand, Zähler, letzter Fehler |
| GET | `/messages` | alle | Liste; `search`, `filter`, `limit`, `offset` | | GET | `/messages` | alle | Liste; `search`, `filter`, `limit`, `offset` |
| GET | `/messages/:id` | alle | Mail mit Text, Zuordnungen, Vorschlaegen | | GET | `/messages/:id` | alle | Mail mit Text, Zuordnungen, Vorschlägen |
| POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity haengen | | POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity hängen |
| DELETE | `/links/:id` | alle | Zuordnung loesen | | DELETE | `/links/:id` | alle | Zuordnung lösen |
| GET | `/candidates` | alle | Kontakt-Vorschlaege, `status` waehlbar | | GET | `/candidates` | alle | Kontakt-Vorschläge, `status` wählbar |
| POST | `/candidates/:id/adopt` | alle | Kontakt anlegen bzw. ergaenzen; `target: 'pool'` legt in „Aus Mails" ab | | POST | `/candidates/:id/adopt` | alle | Kontakt anlegen bzw. ergänzen; `target: 'pool'` legt in „Aus Mails" ab |
| POST | `/candidates/:id/reject` | alle | Vorschlag verwerfen | | POST | `/candidates/:id/reject` | alle | Vorschlag verwerfen |
Der Vorbereich haengt an den Kontakten und hat seine Endpunkte deshalb unter Der Vorbereich hängt an den Kontakten und hat seine Endpunkte deshalb unter
`/api/leads`: `/api/leads`:
| Methode | Pfad | Zweck | | Methode | Pfad | Zweck |
| --- | --- | --- | | --- | --- | --- |
| GET | `/leads/mail-pool/sources` | Herkunft je Eintrag: Mailanzahl, letzter Betreff, Zuversicht | | GET | `/leads/mail-pool/sources` | Herkunft je Eintrag: Mailanzahl, letzter Betreff, Zuversicht |
| POST | `/leads/mail-pool/promote` | mehrere Eintraege als neue Kontakte anlegen | | POST | `/leads/mail-pool/promote` | mehrere Einträge als neue Kontakte anlegen |
| POST | `/leads/:id/promote` | einen Eintrag uebernehmen; mit `target_lead_id` und `fields` in einen vorhandenen Kontakt | | POST | `/leads/:id/promote` | einen Eintrag übernehmen; mit `target_lead_id` und `fields` in einen vorhandenen Kontakt |
`keep_pool: true` behaelt den Eintrag in „Aus Mails"; ohne das verschwindet er `keep_pool: true` behält den Eintrag in „Aus Mails"; ohne das verschwindet er
dort, sobald er als Kontakt gefuehrt wird. In beiden Faellen wandern die dort, sobald er als Kontakt geführt wird. In beiden Fällen wandern die
Mailverknuepfungen mit an den Zielkontakt. Mailverknüpfungen mit an den Zielkontakt.
| GET | `/settings` | admin | verstellbare Einstellungen | | GET | `/settings` | admin | verstellbare Einstellungen |
| POST | `/settings` | admin | Einstellungen speichern | | POST | `/settings` | admin | Einstellungen speichern |
| POST | `/test` | admin | Anmeldung **und** Postfachzugriff pruefen | | POST | `/test` | admin | Anmeldung **und** Postfachzugriff prüfen |
| POST | `/sync` | admin | Abgleich anstossen, `{"reset":true}` von vorn | | POST | `/sync` | admin | Abgleich anstoßen, `{"reset":true}` von vorn |
Lesen ist fuer jeden Angemeldeten frei - `crm@perinco.de` ist ein gemeinsames Lesen ist für jeden Angemeldeten frei - `crm@perinco.de` ist ein gemeinsames
Vertriebspostfach. Konfigurieren, Testen und Anstossen sind Administratoren Vertriebspostfach. Konfigurieren, Testen und Anstossen sind Administratoren
vorbehalten. vorbehalten.
`filter` in der Liste: `unlinked` (noch nirgends zugeordnet - die Arbeitsliste), `filter` in der Liste: `unlinked` (noch nirgends zugeordnet - die Arbeitsliste),
`candidates` (offene Kontakt-Vorschlaege), `linked` (bereits zugeordnet). `candidates` (offene Kontakt-Vorschläge), `linked` (bereits zugeordnet).
## Was anders ist als im Altsystem ## Was anders ist als im Altsystem
| Thema | KeepTrack | perinco CRM | | Thema | KeepTrack | perinco CRM |
| --- | --- | --- | | --- | --- | --- |
| Fortschritt | `$filter=isRead eq false`, danach `PATCH isRead` | Delta Query mit gespeichertem `delta_link` | | Fortschritt | `$filter=isRead eq false`, danach `PATCH isRead` | Delta Query mit gespeichertem `delta_link` |
| Berechtigung | Schreibzugriff noetig | `Mail.Read` genuegt | | Berechtigung | Schreibzugriff nötig | `Mail.Read` genügt |
| Der Haken daran | `PATCH` ging auf `/me/…` - unter App-only gibt es kein `/me`. Der Aufruf lief in einen 400, die Antwort wurde nicht geprueft. Mails blieben ungelesen und wurden bei **jedem** Lauf erneut durch das Sprachmodell geschickt. | entfaellt | | Der Haken daran | `PATCH` ging auf `/me/…` - unter App-only gibt es kein `/me`. Der Aufruf lief in einen 400, die Antwort wurde nicht geprüft. Mails blieben ungelesen und wurden bei **jedem** Lauf erneut durch das Sprachmodell geschickt. | entfällt |
| Tenant-/Client-ID | kamen vom Browser mit; jeder Angemeldete konnte das Server-Secret gegen einen fremden Tenant verwenden | serverseitig aus der `.env`, nie im Browser | | Tenant-/Client-ID | kamen vom Browser mit; jeder Angemeldete konnte das Server-Secret gegen einen fremden Tenant verwenden | serverseitig aus der `.env`, nie im Browser |
| Kontakte ohne Adresse | Platzhalter `name@kontakt-aus-email.local` erfunden, um eine Datenbankbedingung zu erfuellen | Kontakt ohne Adresse; Vorschlag bleibt zur Durchsicht liegen | | Kontakte ohne Adresse | Platzhalter `name@kontakt-aus-email.local` erfunden, um eine Datenbankbedingung zu erfüllen | Kontakt ohne Adresse; Vorschlag bleibt zur Durchsicht liegen |
| Vorhandener Kontakt | wurde uebersprungen | leere Felder werden ergaenzt, gepflegte bleiben | | Vorhandener Kontakt | wurde übersprungen | leere Felder werden ergänzt, gepflegte bleiben |
| Kontaktdaten lesen | kompletter Mailtext an OpenAI, auch fuer Daten, die Graph strukturiert liefert | Graph-Felder plus Signaturauswertung, kein externer Dienst | | Kontaktdaten lesen | kompletter Mailtext an OpenAI, auch für Daten, die Graph strukturiert liefert | Graph-Felder plus Signaturauswertung, kein externer Dienst |
| Mail am Deal | Text in das Freitextfeld `notizen` vorangestellt | eigene Tabelle `crm_mail_links`, loesbar, mehrfach moeglich | | Mail am Deal | Text in das Freitextfeld `notizen` vorangestellt | eigene Tabelle `crm_mail_links`, lösbar, mehrfach möglich |
| Verbindungstest | prueft nur, ob ein Token kommt | prueft Token **und** Postfachzugriff, mit Hinweis je Fehlercode | | Verbindungstest | prüft nur, ob ein Token kommt | prüft Token **und** Postfachzugriff, mit Hinweis je Fehlercode |
| Paging | `@odata.nextLink` ignoriert, `$top=50` fest | Seiten werden gefolgt, je Lauf gedeckelt, fortsetzbar | | Paging | `@odata.nextLink` ignoriert, `$top=50` fest | Seiten werden gefolgt, je Lauf gedeckelt, fortsetzbar |
| HTML-Anzeige | ungefiltert | serverseitig entschaerft, `sandbox=""`, externe Bilder gesperrt | | HTML-Anzeige | ungefiltert | serverseitig entschärft, `sandbox=""`, externe Bilder gesperrt |
| Zeitangaben | gemischt | durchgehend UTC in der Datenbank, Umrechnung im Frontend | | Zeitangaben | gemischt | durchgehend UTC in der Datenbank, Umrechnung im Frontend |
## Was noch nicht drin ist ## Was noch nicht drin ist
* **Senden, Antworten, Weiterleiten.** Braucht `Mail.ReadWrite` und `Mail.Send` * **Senden, Antworten, Weiterleiten.** Braucht `Mail.ReadWrite` und `Mail.Send`
und damit eine erneute Administratorzustimmung. und damit eine erneute Administratorzustimmung.
* **Anhaenge.** `has_attachments` wird gefuehrt und angezeigt, die Dateien * **Anhänge.** `has_attachments` wird geführt und angezeigt, die Dateien
selbst werden nicht geholt. selbst werden nicht geholt.
* **Automatische Zuordnung zu Opportunities.** Die Zuordnung erfolgt per Hand; * **Automatische Zuordnung zu Opportunities.** Die Zuordnung erfolgt per Hand;
der Dialog schlaegt anhand der Mailadressen Kontakt und Opportunity vor. Eine der Dialog schlägt anhand der Mailadressen Kontakt und Opportunity vor. Eine
Bewertung durch ein Sprachmodell waere der naechste Schritt - dann bitte mit Bewertung durch ein Sprachmodell wäre der nächste Schritt - dann bitte mit
dem Schutz gegen Prompt-Injection, der im Altsystem in `mail-sync` fehlte. dem Schutz gegen Prompt-Injection, der im Altsystem in `mail-sync` fehlte.
* **Echtzeit statt Intervall.** Graph Change Notifications brauchen einen * **Echtzeit statt Intervall.** Graph Change Notifications brauchen einen
oeffentlich erreichbaren Endpunkt (haben wir) und eine Erneuerung der öffentlich erreichbaren Endpunkt (haben wir) und eine Erneuerung der
Subscription etwa alle drei Tage. Subscription etwa alle drei Tage.
## Stoerungssuche ## Störungssuche
| Beobachtung | Ursache und Abhilfe | | Beobachtung | Ursache und Abhilfe |
| --- | --- | | --- | --- |
| Einstellungen melden fehlende Variablen | `server/.env` ergaenzen, Dienst neu starten - die Werte werden beim Start gelesen | | Einstellungen melden fehlende Variablen | `server/.env` ergänzen, Dienst neu starten - die Werte werden beim Start gelesen |
| Test scheitert „bei der Anmeldung" | Tenant-ID, Client-ID oder Geheimnis falsch, oder das Geheimnis ist abgelaufen | | Test scheitert „bei der Anmeldung" | Tenant-ID, Client-ID oder Geheimnis falsch, oder das Geheimnis ist abgelaufen |
| Test scheitert „beim Postfachzugriff", `ErrorAccessDenied` | Administratorzustimmung fehlt, oder die ApplicationAccessPolicy erlaubt dieses Postfach nicht | | Test scheitert „beim Postfachzugriff", `ErrorAccessDenied` | Administratorzustimmung fehlt, oder die ApplicationAccessPolicy erlaubt dieses Postfach nicht |
| Test scheitert mit `ResourceNotFound` | `MS_MAILBOX` zeigt auf ein Postfach, das es nicht gibt | | Test scheitert mit `ResourceNotFound` | `MS_MAILBOX` zeigt auf ein Postfach, das es nicht gibt |
| „Erstabgleich laeuft noch" bleibt stehen | Log auf `[Mail]` pruefen; `MAIL_MAX_PAGES` erhoehen, wenn es zu langsam vorangeht | | „Erstabgleich läuft noch" bleibt stehen | Log auf `[Mail]` prüfen; `MAIL_MAX_PAGES` erhöhen, wenn es zu langsam vorangeht |
| Keine Kontakt-Vorschlaege | stehen alle Absender auf einer eigenen Domain? `mail_internal_domains` pruefen | | Keine Kontakt-Vorschläge | stehen alle Absender auf einer eigenen Domain? `mail_internal_domains` prüfen |
| Doppelte Kontakte | tritt nicht auf: Abgleich erfolgt ueber die Adresse in Kleinschreibung; bei Treffer wird ergaenzt statt angelegt | | Doppelte Kontakte | tritt nicht auf: Abgleich erfolgt über die Adresse in Kleinschreibung; bei Treffer wird ergänzt statt angelegt |
| Zaehler und Liste weichen ab | die Liste blendet im Postfach geloeschte Mails aus, der Zaehler in den Einstellungen ebenfalls - Zuordnungen bleiben trotzdem erhalten | | Zähler und Liste weichen ab | die Liste blendet im Postfach gelöschte Mails aus, der Zähler in den Einstellungen ebenfalls - Zuordnungen bleiben trotzdem erhalten |
+12 -12
View File
@@ -3,24 +3,24 @@
Technische Dokumentation mit Funktionszeichnungen. Die Diagramme sind Mermaid und Technische Dokumentation mit Funktionszeichnungen. Die Diagramme sind Mermaid und
werden von Gitea direkt gerendert. werden von Gitea direkt gerendert.
**Neu hier?** Wer die Anwendung *benutzen* will, faengt bei **Neu hier?** Wer die Anwendung *benutzen* will, fängt bei
[07 Anleitung fuer Benutzer](07-anleitung-benutzer.md) an. Wer daran *entwickeln* [07 Anleitung für Benutzer](07-anleitung-benutzer.md) an. Wer daran *entwickeln*
will, bei [08 Anleitung fuer Entwickler](08-anleitung-entwickler.md). Beide setzen will, bei [08 Anleitung für Entwickler](08-anleitung-entwickler.md). Beide setzen
keine Vorkenntnisse voraus. keine Vorkenntnisse voraus.
| Kapitel | Inhalt | | Kapitel | Inhalt |
| --- | --- | | --- | --- |
| [01 Architektur](01-architektur.md) | Topologie, Prozesse, Ports, Anfrageweg, Rollen | | [01 Architektur](01-architektur.md) | Topologie, Prozesse, Ports, Anfrageweg, Rollen |
| [02 Datenmodell](02-datenmodell.md) | Tabellen beider Datenbanken als ER-Diagramme | | [02 Datenmodell](02-datenmodell.md) | Tabellen beider Datenbanken als ER-Diagramme |
| [03 Projekt Scanner](03-scanner.md) | Suchlauf von der Boerse bis zum Treffer, Parallelbetrieb | | [03 Projekt Scanner](03-scanner.md) | Suchlauf von der Börse bis zum Treffer, Parallelbetrieb |
| [04 Diktat](04-diktat.md) | Spracherkennung im Browser, Modell-Lebenszyklus | | [04 Diktat](04-diktat.md) | Spracherkennung im Browser, Modell-Lebenszyklus |
| [05 Frontend](05-frontend.md) | Ansichten, Menue, Komponentenkarte, Zustand | | [05 Frontend](05-frontend.md) | Ansichten, Menü, Komponentenkarte, Zustand |
| [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Stoerungssuche | | [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Störungssuche |
| [07 Anleitung fuer Benutzer](07-anleitung-benutzer.md) | Bedienung aller Bereiche, Schritt fuer Schritt | | [07 Anleitung für Benutzer](07-anleitung-benutzer.md) | Bedienung aller Bereiche, Schritt für Schritt |
| [08 Anleitung fuer Entwickler](08-anleitung-entwickler.md) | Einrichten, erweitern, bekannte Fallen | | [08 Anleitung für Entwickler](08-anleitung-entwickler.md) | Einrichten, erweitern, bekannte Fallen |
| [09 Quellcode-Referenz](09-quellcode.md) | jede Datei, ihre Exporte und Zusagen | | [09 Quellcode-Referenz](09-quellcode.md) | jede Datei, ihre Exporte und Zusagen |
| [10 Glossar](10-glossar.md) | Agent, Match, Lead, Opportunity, Status- und Portalwerte | | [10 Glossar](10-glossar.md) | Agent, Match, Lead, Opportunity, Status- und Portalwerte |
| [11 Postfachanbindung](11-postfach.md) | crm@perinco.de ueber Microsoft Graph, Kontakte aus Mails | | [11 Postfachanbindung](11-postfach.md) | crm@perinco.de über Microsoft Graph, Kontakte aus Mails |
Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im
[README des Projekts](../README.md). [README des Projekts](../README.md).
@@ -47,7 +47,7 @@ flowchart TB
end end
OAI["OpenAI<br/>gpt-4o-mini"] OAI["OpenAI<br/>gpt-4o-mini"]
BOERSE["Projektboersen<br/>freelancermap, hays, ..."] BOERSE["Projektbörsen<br/>freelancermap, hays, ..."]
GRAPH["Microsoft Graph<br/>crm@perinco.de"] GRAPH["Microsoft Graph<br/>crm@perinco.de"]
SPA -->|"HTTPS /api"| AP SPA -->|"HTTPS /api"| AP
@@ -70,5 +70,5 @@ flowchart TB
class ALT,DB3 alt class ALT,DB3 alt
``` ```
Gestrichelt: die urspruengliche Scanner-App, die bis zur Ablosung parallel Gestrichelt: die ursprüngliche Scanner-App, die bis zur Ablosung parallel
weiterlaeuft. Sie hat ihre eigene Datenbank und wird vom CRM nicht angefasst. weiterläuft. Sie hat ihre eigene Datenbank und wird vom CRM nicht angefasst.