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
CRM im perinco Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB.
Enthaelt den portierten *Projekt Scanner* und die lesende Anbindung des
Vertriebspostfachs `crm@perinco.de` ueber Microsoft Graph.
Enthält den portierten *Projekt Scanner* und die lesende Anbindung des
Vertriebspostfachs `crm@perinco.de` über Microsoft Graph.
> **Dokumentation mit Funktionszeichnungen:** [`docs/`](docs/README.md)
> — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb,
> [Postfachanbindung](docs/11-postfach.md).
> Zum Einstieg: [Anleitung fuer Benutzer](docs/07-anleitung-benutzer.md) ·
> [Anleitung fuer Entwickler](docs/08-anleitung-entwickler.md) ·
> Zum Einstieg: [Anleitung für Benutzer](docs/07-anleitung-benutzer.md) ·
> [Anleitung für Entwickler](docs/08-anleitung-entwickler.md) ·
> [Quellcode-Referenz](docs/09-quellcode.md)
## Corporate Identity
@@ -17,12 +17,12 @@ Direkt aus dem perinco Logo gesampelt:
| Rolle | Wert |
| --- | --- |
| Primaerfarbe (Gruen) | `#6BA04A`, Akzent `#68A33F` |
| Sekundaerfarbe (Grau) | `#6E6E6E` |
| Primärfarbe (Grün) | `#6BA04A`, Akzent `#68A33F` |
| Sekundärfarbe (Grau) | `#6E6E6E` |
| Schrift | Avenir LT (wie perinco.de); Fallback Nunito Sans |
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
@@ -32,21 +32,21 @@ Doppelklick auf den Umschalter stellt das wieder her.
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
`canvas`, `surface`, `ink-*`, `brand-*` und der getoenten Statusfarben. Die
Komponenten benutzen durchgaengig diese Skalen und folgen damit automatisch.
`canvas`, `surface`, `ink-*`, `brand-*` und der getönten Statusfarben. Die
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 |
| --- | --- |
| `ink-950` | liegt nur unter Dialog-Overlays, muss dunkel bleiben |
| `text-white` | steht immer auf farbigen Flaechen (Buttons, Termin-Chips) |
| `brand-500` | das CI-Gruen der Buttons; nur die Tints darum kippen |
| `text-white` | steht immer auf farbigen Flächen (Buttons, Termin-Chips) |
| `brand-500` | das CI-Grün der Buttons; nur die Tints darum kippen |
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
`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
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)
```
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.
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.
### Datenmodell
@@ -74,25 +74,25 @@ den Apache-Proxy erreichbar.
| Tabelle | Inhalt |
| --- | --- |
| `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_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_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
* `admin` - sieht den Menuepunkt Einstellungen inkl. Nutzerverwaltung
* `user` - sieht Einstellungen nicht; die API weist Zugriffe zusaetzlich mit 403 ab
* `admin` - sieht den Menüpunkt Einstellungen inkl. Nutzerverwaltung
* `user` - sieht Einstellungen nicht; die API weist Zugriffe zusätzlich mit 403 ab
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
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.
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.
## Struktur
@@ -173,40 +173,40 @@ deploy/
## 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:
| 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 |
| **Pipeline** | Kanban nach Phase, Karten per Drag & Drop verschiebbar |
Die dritte Ansicht ist bewusst eine Pipeline statt eines Netzwerks: bei einer
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.
**Felder:** Titel, Firma, Projektbeschreibung (in Kachel und Liste
aufklappbar), Phase, Abschlusswahrscheinlichkeit, Tagessatz, Laufzeit in
Personentagen (halbe Tage moeglich), Projektstart und -ende, letzte Interaktion
mit Notiz, naechster Schritt, Tags, interne Notizen, Zustaendigkeit sowie
Personentagen (halbe Tage möglich), Projektstart und -ende, letzte Interaktion
mit Notiz, nächster Schritt, Tags, interne Notizen, Zuständigkeit sowie
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
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
allen Ansichten) und in der Liste ueber die Spaltenkoepfe. Geld- und Datumsfelder
**Sortieren** geht doppelt: über das Auswahlfeld in der Steuerleiste (wirkt in
allen Ansichten) und in der Liste über die Spaltenköpfe. Geld- und Datumsfelder
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
Der eigenstaendige *Projekt Scanner* ist vollstaendig hier eingezogen und soll
die alte App ersetzen. Die laeuft weiter unter `scanner.perinco.de`:
Der eigenständige *Projekt Scanner* ist vollständig hier eingezogen und soll
die alte App ersetzen. Die läuft weiter unter `scanner.perinco.de`:
```
/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
Datenbank, eigener Verschluesselungsschluessel, eigenes Upload-Verzeichnis,
eigener Scheduler. Die alte App bleibt unveraendert - weder ihre Dateien noch
Datenbank, eigener Verschlüsselungsschlüssel, eigenes Upload-Verzeichnis,
eigener Scheduler. Die alte App bleibt unverändert - weder ihre Dateien noch
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:
| Reiter | Inhalt |
| --- | --- |
| **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 |
| **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
*Scanner* unter *Einstellungen* - also nur fuer Rolle `admin`. Die Reiter selbst
Die **Einstellungen** des Scanners stehen nicht im Untermenü, sondern als Reiter
*Scanner* unter *Einstellungen* - also nur für Rolle `admin`. Die Reiter selbst
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
@@ -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/` |
| 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` |
| Oberflaeche | `src/components/scanner/`, in Tailwind und CI-Farben neu gebaut |
| Uebernahme aus der alten DB | `server/src/scanner/importLegacy.js` (`npm run scanner:import`) |
| Oberfläche | `src/components/scanner/`, in Tailwind und CI-Farben neu gebaut |
| Übernahme aus der alten DB | `server/src/scanner/importLegacy.js` (`npm run scanner:import`) |
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.
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
mitschicken, deshalb holt das Frontend die Datei und oeffnet sie als Blob.
3. **Meldungen** laufen als Toast statt `alert()`, Loeschabfragen als Dialog.
mitschicken, deshalb holt das Frontend die Datei und öffnet sie als Blob.
3. **Meldungen** laufen als Toast statt `alert()`, Löschabfragen als Dialog.
### Parallelbetrieb - alles doppelt
Beide Scanner laufen **gleichzeitig und funktional**, ohne sich zu beruehren.
Dafuer ist hier jede geteilte Ressource verdoppelt:
Beide Scanner laufen **gleichzeitig und funktional**, ohne sich zu berühren.
Dafür ist hier jede geteilte Ressource verdoppelt:
| | alte App | diese Instanz |
| --- | --- | --- |
| 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` |
| 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**
(`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.
| Variable | Bedeutung |
| --- | --- |
| `SCANNER_DB_NAME` | eigene Datenbank, Standard `perinco_scanner` - **nicht** auf `project_scanner` zeigen lassen |
| `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_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
> 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
> dann weiter.
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
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
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)).
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,
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
und Einstellungen herueber. Es liest die alte Datenbank nur, geschrieben wird
ausschliesslich in `perinco_scanner`:
Damit die neue Instanz nicht bei Null anfängt, holt ein Skript Agenten, Portale
und Einstellungen herüber. Es liest die alte Datenbank nur, geschrieben wird
ausschließlich in `perinco_scanner`:
```bash
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`:
| Variable | Wofuer |
| Variable | Wofür |
| --- | --- |
| `LEGACY_DB_NAME` | Standard `project_scanner` |
| `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_ENCRYPTION_KEY` | nur damit lassen sich die Portal-Passwoerter uebernehmen: entschluesseln mit dem alten, neu verschluesseln mit dem eigenen Schluessel |
| `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-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.
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
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
> gespeicherten Portal-Passwoerter lassen sich mit dem dortigen
> `ENCRYPTION_KEY` **nicht** mehr entschluesseln - der Schluessel wurde nach dem
> gespeicherten Portal-Passwörter lassen sich mit dem dortigen
> `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
> also nichts verloren. Wer den Notfall-Login des Scrapers will, gibt die
> Passwoerter unter *Boersen-Konten* neu ein. Die Sessions (Cookies) sind davon
> unberuehrt und wurden uebernommen.
> Passwörter unter *Börsen-Konten* neu ein. Die Sessions (Cookies) sind davon
> unberührt und wurden übernommen.
### Playwright
`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
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
Version wuerde beides aufgeben, deshalb steht in `server/package.json` ein
`comments`-Feld mit dieser Begruendung.
Version würde beides aufgeben, deshalb steht in `server/package.json` ein
`comments`-Feld mit dieser Begründung.
## Diktat (Whisper im Browser)
Ueberall, wo laengerer Text entsteht - Projektbeschreibung, Interaktionsnotiz,
naechster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung
laeuft **vollstaendig im Browser des Benutzers**: Whisper als ONNX-Modell ueber
Überall, wo längerer Text entsteht - Projektbeschreibung, Interaktionsnotiz,
nächster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung
läuft **vollständig im Browser des Benutzers**: Whisper als ONNX-Modell über
`@huggingface/transformers`, gerechnet auf der Grafikkarte (WebGPU) oder
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
werden nirgends gespeichert.
* Die Erkennung laeuft in einem **Web Worker** (`src/lib/whisper.worker.ts`),
damit die Oberflaeche waehrenddessen nicht einfriert.
* Die Erkennung läuft in einem **Web Worker** (`src/lib/whisper.worker.ts`),
damit die Oberfläche währenddessen nicht einfriert.
* Die **ONNX-Laufzeit** wird mit ausgeliefert (Vite bundelt die `.wasm`), die
**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
@@ -375,42 +375,42 @@ werden nirgends gespeichert.
### 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:
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
3. Fertig - ab jetzt sind die Mikrofon-Buttons in den Formularen aktiv, auch
nach jeder weiteren Anmeldung
Dazu gibt es eine Probeaufnahme und **Sprachmodell loeschen**, falls man den
Platz zurueckhaben will. Sonst nichts: keine Modellauswahl, keine Genauigkeit,
kein Rechenweg, keine Geraetetabelle.
Dazu gibt es eine Probeaufnahme und **Sprachmodell löschen**, falls man den
Platz zurückhaben will. Sonst nichts: keine Modellauswahl, keine Genauigkeit,
kein Rechenweg, keine Gerätetabelle.
Das ist Absicht. Modell, Genauigkeit, Rechenweg und Sprache stehen als
Konstanten in `src/lib/whisper.tsx` - `small`, `q4`, Grafikkarte wenn vorhanden,
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.
Beim Anmelden wird das Modell automatisch bereitgestellt, **wenn** es schon in
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.
### Wo das Modell liegt und wie lange
Nicht in Cookies - dort passen nur wenige Kilobyte. Die Dateien liegen im
**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
die Dateien bei Platznot nicht mehr selbst wegraeumen. Chrome entscheidet darueber
nach eigenen Kriterien und lehnt haeufig ab - dann funktioniert alles genauso, nur
die Dateien bei Platznot nicht mehr selbst wegräumen. Chrome entscheidet darüber
nach eigenen Kriterien und lehnt häufig ab - dann funktioniert alles genauso, nur
ohne diese Zusicherung.
Geraet oder Browserprofil gewechselt? Dann faellt der Download erneut an; der
Speicher gehoert zum Profil, nicht zum Benutzerkonto. Findet die App das Modell
nach dem Laden nicht im Cache, sagt sie es ausdruecklich.
Gerät oder Browserprofil gewechselt? Dann fällt der Download erneut an; der
Speicher gehört zum Profil, nicht zum Benutzerkonto. Findet die App das Modell
nach dem Laden nicht im Cache, sagt sie es ausdrücklich.
### 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 |
| **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.
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
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).
`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.
### Welche Genauigkeit auf welchem Rechenweg
@@ -443,41 +443,41 @@ Deshalb ist `q4` festgelegt:
| | 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 |
| **WASM** | laeuft | small 10,3 s | ungetestet | 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** | 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
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
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.
Zusaetzlich hat jede Erkennung eine Frist (15x Aufnahmedauer, mindestens 45 s,
hoechstens 5 min). Laeuft sie ab, wird der Worker verworfen und der Fehler
Zusätzlich hat jede Erkennung eine Frist (15x Aufnahmedauer, mindestens 45 s,
höchstens 5 min). Läuft sie ab, wird der Worker verworfen und der Fehler
gemeldet, statt dauerhaft "Erkenne..." zu zeigen.
### Die Groessengrenze des Browsers
### Die Größengrenze des Browsers
Nicht die Gesamtgroesse entscheidet, sondern die **groesste Einzeldatei** - der
Browser muss sie in einem zusammenhaengenden Speicherblock halten. Ueber etwa
Nicht die Gesamtgröße entscheidet, sondern die **größte Einzeldatei** - der
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
(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.
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.
> **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
> zwischengespeichert wird. Das war falsch - ein Artefakt der Testumgebung.
> Von Playwright gestartete Chrome-Instanzen schreiben den Cache-Speicher nicht,
> 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
> 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
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.
## Kalender
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 Wochentagskopf springt in die Tagesansicht.
* **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
abgeleitet (Palette in `src/types.ts`). Aenderbar unter *Einstellungen → Mein
Konto* und - fuer Admins fuer alle - in der Nutzerverwaltung. Die Legende
unter dem Kalender zeigt, welche Farbe zu wem gehoert.
abgeleitet (Palette in `src/types.ts`). Änderbar unter *Einstellungen → Mein
Konto* und - für Admins für alle - in der Nutzerverwaltung. Die Legende
unter dem Kalender zeigt, welche Farbe zu wem gehört.
* **Teilnehmer** Ein Termin kann beliebig viele Benutzer haben; sie erscheinen
mit Foto - oder, wenn keines hinterlegt ist, als Initiale auf ihrer
Kalenderfarbe - und sind anklickbar.
* **@-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
sind Mentions klickbar: Kontakte oeffnen das Kontaktfenster, Benutzer ein
Detailfenster mit Farbe, naechsten Terminen und zugeordneten Kontakten.
sind Mentions klickbar: Kontakte öffnen das Kontaktfenster, Benutzer ein
Detailfenster mit Farbe, nächsten Terminen und zugeordneten Kontakten.
Existiert die Person nicht mehr, bleibt der Name als grauer Text stehen.
* **Naechste Termine** Rechts neben dem Kalender, nach Tagen gruppiert und
zeitlich sortiert - der naechste anstehende Termin steht oben. Vergangene
* **Nächste Termine** Rechts neben dem Kalender, nach Tagen gruppiert und
zeitlich sortiert - der nächste anstehende Termin steht oben. Vergangene
Termine fallen aus der Liste, bleiben im Kalender aber erhalten.
## Fotos
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
(`src/lib/imageCompress.ts`):
* mittiger Ausschnitt auf ein Quadrat von 256 px - mehr braucht keine Ansicht,
die groesste ist 80 px
* WebP mit Qualitaet 0,82, Fallback JPEG, dann PNG
die größte ist 80 px
* WebP mit Qualität 0,82, Fallback JPEG, dann PNG
* aus einem 2,9-MB-Foto werden so einige Kilobyte
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.
Benutzerfotos pflegt jeder fuer sich unter *Einstellungen → Mein
Konto*, Admins fuer alle in der Nutzerverwaltung. Angezeigt werden sie in der
Benutzerfotos pflegt jeder für sich unter *Einstellungen → Mein
Konto*, Admins für alle in der Nutzerverwaltung. Angezeigt werden sie in der
Sidebar, bei Terminteilnehmern und Erstellern, im Benutzer-Detailfenster, in den
@-Vorschlaegen, in der Nutzerverwaltung und als Benutzerknoten im
Netzwerkdiagramm. Ohne Foto erscheint ueberall die Initiale auf der
@-Vorschlägen, in der Nutzerverwaltung und als Benutzerknoten im
Netzwerkdiagramm. Ohne Foto erscheint überall die Initiale auf der
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
aktuelle Uhrzeit.
@@ -550,14 +550,14 @@ aktuelle Uhrzeit.
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
* **Gruene Linie** = Kontakt &harr; Kontakt (Dicke nach Verbindungsstaerke)
* **Grüne Linie** = Kontakt &harr; Kontakt (Dicke nach Verbindungsstärke)
* **Graue gestrichelte Linie** = Ersteller &rarr; Kontakt
Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt auf den Cursor,
Knoten ziehen fixiert ihn (gruener Punkt), Doppelklick loest die Fixierung.
Ueber *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen;
Knoten ziehen fixiert ihn (grüner Punkt), Doppelklick löst die Fixierung.
Über *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen;
Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter.
## Lokale Entwicklung
@@ -569,7 +569,7 @@ npm start # Backend :5006
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
Scanner-Datenbank `perinco_scanner` legt es komplett selbst an, inklusive
Tabellen, Portalen und Standardeinstellungen.
@@ -611,19 +611,19 @@ Abschnitt *Playwright* oben.
> **Reihenfolge beachten:** Der Import schreibt `search_interval` aus der alten
> 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
> Scrapen abgeschossen hat (`Target page ... has been closed` im Log, harmlos).
> Wer das vermeiden will, macht Schritt 3 vor Schritt 2.
Das hoehere `MemoryMax` (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet
Chromium als Kindprozess in derselben cgroup, mit 512M wuerde der OOM-Killer das
Backend mitten im Scrapen beenden. `deploy.ps1` laedt die Unit nicht mit hoch -
Das höhere `MemoryMax` (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet
Chromium als Kindprozess in derselben cgroup, mit 512M würde der OOM-Killer das
Backend mitten im Scrapen beenden. `deploy.ps1` lädt die Unit nicht mit hoch -
daher der `cp` oben.
> **RAM im Parallelbetrieb:** Im schlechtesten Fall scrapen beide Scanner
> 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
> 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
`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
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:
| 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` |
| Datenbank | MariaDB 10.11 | `perinco_CRM` und `perinco_scanner` |
Der Browser spricht **nie** direkt mit MariaDB - deshalb ueberhaupt ein Backend.
Das Backend bindet ausschliesslich auf `127.0.0.1` und ist von aussen nur ueber
Der Browser spricht **nie** direkt mit MariaDB - deshalb überhaupt ein Backend.
Das Backend bindet ausschließlich auf `127.0.0.1` und ist von außen nur über
den Apache-Reverse-Proxy erreichbar.
## Topologie
@@ -33,20 +33,20 @@ flowchart LR
class U2,A2,E2,S2 alt
```
Der Scanner im CRM haengt unter `/api/scanner` und braucht damit **keine eigene
Apache-Regel**: alles laeuft ueber den bestehenden `/api`-Proxy. Dasselbe gilt fuer
Der Scanner im CRM hängt unter `/api/scanner` und braucht damit **keine eigene
Apache-Regel**: alles läuft über den bestehenden `/api`-Proxy. Dasselbe gilt für
die Postfachanbindung unter `/api/mail` (siehe
[11 Postfachanbindung](11-postfach.md)).
## Ausgehende Verbindungen
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.
| 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 |
| `login.microsoftonline.com`, `graph.microsoft.com` | Postfach `crm@perinco.de` lesen | Mail-Scheduler |
@@ -80,10 +80,10 @@ sequenceDiagram
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
jeder Anfrage neu gegen die Datenbank geprueft - ein deaktivierter Benutzer ist
sofort draussen, ohne auf den Ablauf des Tokens zu warten.
jeder Anfrage neu gegen die Datenbank geprüft - ein deaktivierter Benutzer ist
sofort draußen, ohne auf den Ablauf des Tokens zu warten.
## 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
sind Administratoren vorbehalten.
Die Rollenpruefung sitzt **doppelt**: das Menue blendet aus, was nicht erlaubt
ist, und das Backend weist Zugriffe zusaetzlich mit `403` ab (`requireAdmin`).
Die Rollenprüfung sitzt **doppelt**: das Menü blendet aus, was nicht erlaubt
ist, und das Backend weist Zugriffe zusätzlich mit `403` ab (`requireAdmin`).
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.
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
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 |
| --- | --- |
+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_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
der eigenstaendigen App. Gleiche Tabellennamen, gleiche Spalten - nur in einer
eigenen Datenbank, damit beide Anwendungen parallel laufen koennen.
Die Trennung ist kein Zufall: die Scanner-Tabellen sind eine 1:1-Übernahme aus
der eigenständigen App. Gleiche Tabellennamen, gleiche Spalten - nur in einer
eigenen Datenbank, damit beide Anwendungen parallel laufen können.
## perinco_CRM
@@ -61,7 +61,7 @@ erDiagram
enum stage "Neu .. Verloren"
tinyint probability "0-100"
decimal daily_rate
decimal duration_days "Personentage, halbe moeglich"
decimal duration_days "Personentage, halbe möglich"
date last_interaction_at
varchar next_step
int owner_id FK
@@ -88,15 +88,15 @@ erDiagram
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
Postfachabgleichs. Nur der Kontaktbestand erscheint in Kontaktliste,
Opportunities, Kalender und Netzwerk; der Vorbereich hat seine eigene Ansicht.
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)).
- **`tags` ist Text, kein JSON-Typ.** Bewusst, damit es auf aelteren
MariaDB-Versionen ohne JSON-Funktionen laeuft. Das Backend serialisiert.
- **`tags` ist Text, kein JSON-Typ.** Bewusst, damit es auf älteren
MariaDB-Versionen ohne JSON-Funktionen läuft. Das Backend serialisiert.
- **`crm_lead_connections` speichert das Paar sortiert** (kleinere Id in
`from_lead_id`). Der Unique-Key verhindert damit auch die Umkehrung als
Duplikat, obwohl die Verbindung ungerichtet gedacht ist.
@@ -106,7 +106,7 @@ Vier Eigenheiten, die man kennen muss:
### 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
Beschreibung in [11 Postfachanbindung](11-postfach.md).
@@ -116,21 +116,21 @@ erDiagram
crm_mail_messages ||--o{ crm_mail_contact_candidates : liefert
crm_leads ||--o{ crm_mail_links : "lead_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 {
int id PK
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 subject
varchar from_email
longtext to_json "[{name,email}] als Text"
datetime received_at "UTC"
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 is_deleted "im Postfach geloescht"
tinyint is_deleted "im Postfach gelöscht"
}
crm_mail_links {
int id PK
@@ -151,13 +151,13 @@ erDiagram
varchar company
enum origin "absender | empfaenger | signatur | text"
tinyint confidence "0-100"
enum status "offen | uebernommen | verworfen | dublette"
int lead_id FK "gesetzt ab uebernommen"
enum status "offen | übernommen | verworfen | dublette"
int lead_id FK "gesetzt ab übernommen"
}
crm_mail_sync_state {
tinyint id PK "immer 1"
varchar mailbox
text delta_link "naechster Graph-Aufruf"
text delta_link "nächster Graph-Aufruf"
tinyint has_more "nextLink offen"
datetime last_run_at
datetime last_success_at
@@ -171,20 +171,20 @@ erDiagram
Vier Eigenheiten:
- **Die Graph-ID hat einen Hash daneben.** Sie ist mit ueber 150 Zeichen zu lang
fuer einen Unique-Index unter `utf8mb4`; die Eindeutigkeit traegt deshalb
- **Die Graph-ID hat einen Hash daneben.** Sie ist mit über 150 Zeichen zu lang
für einen Unique-Index unter `utf8mb4`; die Eindeutigkeit trägt deshalb
`graph_id_hash` (SHA-256 als Hex, `CHAR(64)`).
- **`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
`src/lib/mailApi.ts`).
- **`crm_mail_links` erlaubt NULL in beiden Fremdschluesseln.** MariaDB laesst in
einem Unique-Index mehrere NULL zu - die beiden Schluessel sperren damit genau
- **`crm_mail_links` erlaubt NULL in beiden Fremdschlüsseln.** MariaDB lässt in
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
mehreren Opportunities haengen.
- **`crm_settings` enthaelt keine Geheimnisse.** Zugangsdaten fuer Microsoft 365
stehen ausschliesslich in `server/.env`, damit ein Datenbank-Dump keine Secrets
enthaelt.
mehreren Opportunities hängen.
- **`crm_settings` enthält keine Geheimnisse.** Zugangsdaten für Microsoft 365
stehen ausschließlich in `server/.env`, damit ein Datenbank-Dump keine Secrets
enthält.
## perinco_scanner
@@ -197,7 +197,7 @@ erDiagram
agents {
int id PK
varchar name
varchar email "fuer Benachrichtigungen"
varchar email "für Benachrichtigungen"
longtext cv_text "aus dem PDF extrahiert"
varchar cv_file_path "Pfad im Upload-Verzeichnis"
varchar keywords "kommagetrennt, Suchbegriffe"
@@ -207,7 +207,7 @@ erDiagram
projects {
int id PK
varchar portal "freelancermap.de, hays.de, ..."
varchar portal_project_id "Id der Boerse"
varchar portal_project_id "Id der Börse"
varchar title
longtext description
varchar location
@@ -244,12 +244,12 @@ erDiagram
- **`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,
warum ein zweiter Scan-Lauf ueber dieselben Projekte nichts kostet.
- **`projects` wird nach 7 Tagen aufgeraeumt** (`cleanupOldProjects`), die
warum ein zweiter Scan-Lauf über dieselben Projekte nichts kostet.
- **`projects` wird nach 7 Tagen aufgeräumt** (`cleanupOldProjects`), die
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
maskiertes Feld beim Speichern laesst den gespeicherten Wert unberuehrt.
maskiertes Feld beim Speichern lässt den gespeicherten Wert unberührt.
## Statuswege
@@ -259,16 +259,16 @@ stateDiagram-v2
new --> reviewed : "Als Gesehen markieren"
new --> applied : "Als Beworben markieren"
reviewed --> applied
applied --> new : "Zurueck auf Neu"
applied --> new : "Zurück auf Neu"
reviewed --> new
new --> rejected : "Passt nicht"
reviewed --> rejected
applied --> rejected
rejected --> new : "Zurueck ins Dashboard"
rejected --> new : "Zurück ins Dashboard"
rejected --> [*] : loeschen
new --> [*] : loeschen
```
`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.
+39 -39
View File
@@ -1,8 +1,8 @@
# 03 Projekt Scanner
Der Scanner durchsucht Projektboersen nach Auftraegen und bewertet jeden Fund mit
OpenAI gegen die Lebenslaeufe der Berater. Er ist eine vollstaendige Portierung
der eigenstaendigen App und laeuft parallel zu ihr.
Der Scanner durchsucht Projektbörsen nach Aufträgen und bewertet jeden Fund mit
OpenAI gegen die Lebensläufe der Berater. Er ist eine vollständige Portierung
der eigenständigen App und läuft parallel zu ihr.
## Ein Suchlauf
@@ -43,15 +43,15 @@ sequenceDiagram
Zwei Sparmassnahmen stecken bewusst **vor** dem OpenAI-Aufruf: der vorhandene
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
flowchart TD
T["Cron-Tick, jede Minute"] --> A{"scanning_active?"}
A -->|nein| STOP1["nichts tun"]
A -->|ja| B{"Intervall geaendert?"}
A -->|ja| B{"Intervall geändert?"}
B -->|ja| RE["neu planen"]
B -->|nein| C{"Sperrfrist<br/>nur beim allerersten Start"}
C -->|noch aktiv| STOP2["nichts tun"]
@@ -59,7 +59,7 @@ flowchart TD
D -->|nein| STOP3["nichts tun"]
D -->|ja| E{"07:00-21:59?"}
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?"}
F -->|nein| STOP5["nichts tun"]
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.
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`
> gebaut. Ein Zeitstempel kann einen Lauf aber nur **vorziehen**, nie verzoegern -
> **Erster Fehler:** Der Versatz war über einen vorgealterten `lastRunTime`
> 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
> (`notBefore`).
>
> **Zweiter Fehler:** Diese Frist begann bei **jedem** Neustart von vorn. Zwei
> Deployments im Abstand von zehn Minuten haben den faelligen Lauf zweimal um eine
> halbe Stunde verschoben - er fand nie statt. Wer oefter als alle 30 Minuten
> deployt, haette einen Scanner, der nie automatisch sucht. Deshalb liegt der
> Deployments im Abstand von zehn Minuten haben den fälligen Lauf zweimal um eine
> halbe Stunde verschoben - er fand nie statt. Wer öfter als alle 30 Minuten
> 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
> nichts steht.
Der gespeicherte Zeitpunkt wird auch **im Tick** gelesen: ein manueller Scan aus
der Oberflaeche schreibt ihn, weiss aber nichts von der Variablen im Scheduler.
Ohne den Abgleich koennte der Scheduler direkt hinter einem manuellen Lauf noch
der Oberfläche schreibt ihn, weiß aber nichts von der Variablen im Scheduler.
Ohne den Abgleich könnte der Scheduler direkt hinter einem manuellen Lauf noch
einmal losziehen.
## Parallelbetrieb - alles verdoppelt
@@ -107,7 +107,7 @@ flowchart LR
N4["SCANNER_ENCRYPTION_KEY"] --- N1
end
B["Projektboersen"]
B["Projektbörsen"]
O["OpenAI"]
A1 --> B
N1 --> B
@@ -122,16 +122,16 @@ flowchart LR
| | alte App | CRM |
| --- | --- | --- |
| Datenbank | `project_scanner` | `perinco_scanner` |
| Schluessel | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` |
| Schlüssel | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` |
| CV-Dateien | eigener Ordner | eigener Ordner |
| Aufraeumen | 03:00 | 03:30 |
| Aufräumen | 03:00 | 03:30 |
| 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
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`:
@@ -140,9 +140,9 @@ flowchart TD
S["Start"] --> C{"Zieltabelle agents leer?"}
C -->|nein, ohne --force| AB["Abbruch"]
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?"}
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"]
PW2 --> A["Agenten anlegen, Ids behalten<br/>CV-PDF kopieren"]
PW3 --> A
@@ -152,33 +152,33 @@ flowchart TD
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 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
> 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
> 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.
## Oberflaeche
## Oberfläche
| Reiter | Datei | Inhalt |
| --- | --- | --- |
| Dashboard | `ScannerDashboard.tsx` | Kennzahlen, Filter, Trefferliste, *Jetzt scannen*, Ton bei neuen Treffern |
| Unpassend | `ScannerRejected.tsx` | Treffer mit Status `rejected` |
| 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 |
**Wichtig zum Ton:** Er haengt an der Dashboard-Komponente. Wer auf einen anderen
Menuepunkt wechselt, hoert nichts - die Komponente wird ausgebaut und die
**Wichtig zum Ton:** Er hängt an der Dashboard-Komponente. Wer auf einen anderen
Menüpunkt wechselt, hört nichts - die Komponente wird ausgebaut und die
30-Sekunden-Abfrage endet. In einem anderen Browsertab bleibt der Ton dagegen
aktiv, nur langsamer (Chrome bremst Timer versteckter Tabs auf etwa einmal pro
Minute) und nur, wenn zuvor irgendwo auf der Seite geklickt wurde
(Autoplay-Regel). Browserunabhaengig meldet nur die E-Mail.
(Autoplay-Regel). Browserunabhängig meldet nur die E-Mail.
## Endpunkte
@@ -187,15 +187,15 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`:
| Methode | Pfad | Zweck |
| --- | --- | --- |
| 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 |
| GET | `/agents/:id/cv` | PDF als Stream |
| GET | `/portals` | Status ohne Geheimnisse |
| POST | `/portals/credentials` | Zugangsdaten, AES-verschluesselt |
| POST | `/portals/sync-session` | Cookie-Import, normalisiert fuer Playwright |
| POST | `/portals/test-connection/:id` | Mini-Scrape bzw. Session-Pruefung |
| POST | `/portals/credentials` | Zugangsdaten, AES-verschlüsselt |
| POST | `/portals/sync-session` | Cookie-Import, normalisiert für Playwright |
| POST | `/portals/test-connection/:id` | Mini-Scrape bzw. Session-Prüfung |
| 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 |
| POST | `/scan` | Lauf starten, `409` wenn schon einer laeuft |
| GET | `/status` | laeuft ein Lauf, Scheduler an, Datenbankname |
| POST | `/scan` | Lauf starten, `409` wenn schon einer läuft |
| GET | `/status` | läuft ein Lauf, Scheduler an, Datenbankname |
+12 -12
View File
@@ -1,8 +1,8 @@
# 04 Diktat (Whisper im Browser)
Ueberall, wo laengerer Text entsteht - Projektbeschreibung, Interaktionsnotiz,
naechster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung
laeuft **vollstaendig im Browser des Benutzers**.
Überall, wo längerer Text entsteht - Projektbeschreibung, Interaktionsnotiz,
nächster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung
läuft **vollständig im Browser des Benutzers**.
Das Backend ist daran nicht beteiligt und sieht keine Audiodaten: kein API-Key,
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
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
Laden vom CDN und bleiben danach im Cache-Speicher der Seite.
@@ -36,7 +36,7 @@ stateDiagram-v2
loading --> ready : im Worker initialisiert
loading --> error : Abbruch, Haenger, kein Speicher
error --> loading : erneut versuchen
ready --> idle : "Sprachmodell loeschen"
ready --> idle : "Sprachmodell löschen"
ready --> ready : Diktat benutzen
note right of loading
@@ -47,14 +47,14 @@ stateDiagram-v2
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
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.
## Wo es sitzt
Reiter **Whisper** in den Einstellungen - fuer **jede Rolle** sichtbar, denn jeder
richtet sein Diktat auf seinem eigenen Geraet ein. Genau deshalb sind die
Einstellungen ueberhaupt fuer alle Rollen zugaenglich; welche Reiter erscheinen,
Reiter **Whisper** in den Einstellungen - für **jede Rolle** sichtbar, denn jeder
richtet sein Diktat auf seinem eigenen Gerät ein. Genau deshalb sind die
Einstellungen überhaupt für alle Rollen zugänglich; welche Reiter erscheinen,
entscheidet die Rolle:
```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
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
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).
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
Eine einzige React-Anwendung ohne Router: `App.tsx` haelt die aktuelle Ansicht in
einem Zustand und rendert sie. Das genuegt, weil die App hinter einer Anmeldung
Eine einzige React-Anwendung ohne Router: `App.tsx` hält die aktuelle Ansicht in
einem Zustand und rendert sie. Das genügt, weil die App hinter einer Anmeldung
sitzt und keine tiefen Links braucht.
## Menue und Ansichten
## Menü und Ansichten
```mermaid
flowchart TD
@@ -13,15 +13,15 @@ flowchart TD
K --> C["Kalender"]
K --> L["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"]
M --> M1["Postfach"]
M --> M2["Kontakt-Vorschlaege"]
M --> M2["Kontakt-Vorschläge"]
K --> SC["Scanner"]
SC --> SC1["Dashboard"]
SC --> SC2["Unpassend"]
SC --> SC3["Suchagenten"]
SC --> SC4["Boersen-Konten"]
SC --> SC4["Börsen-Konten"]
SB --> E["Einstellungen"]
E --> E1["Nutzerverwaltung (admin)"]
E --> E2["Mein Konto"]
@@ -30,16 +30,16 @@ flowchart TD
E --> E5["Whisper"]
```
*Postfach* und *Kontakt-Vorschlaege* sind Reiter **innerhalb** der Ansicht
*Mails*, keine Menuepunkte - sie gehoeren zusammen und wechseln oft hin und her.
*Postfach* und *Kontakt-Vorschläge* sind Reiter **innerhalb** der Ansicht
*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).
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`.
*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.
## Zustand und Datenfluss
@@ -64,34 +64,34 @@ flowchart TB
DATA --> V2["Kalender"]
DATA --> V3["Kontakte<br/>mail_pool = false"]
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 --> ML["Mails<br/>springt zum Kontakt einer Zuordnung"]
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
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,
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
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
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
vergleicht jeden Fund gegen den Kontaktbestand und markiert Dubletten.
`goTo('Kontakte', { leadId })` ist der Sprung aus einer Mailzuordnung zum
Kontakt. `App.tsx` haelt die Id in `focusLeadId`, die Kontaktansicht oeffnet den
Kontakt und meldet mit `onFocusHandled()` zurueck - der Effekt haengt an `leads`
Kontakt. `App.tsx` hält die Id in `focusLeadId`, die Kontaktansicht öffnet den
Kontakt und meldet mit `onFocusHandled()` zurück - der Effekt hängt an `leads`
und wartet damit auf einen gerade erst angelegten Kontakt.
`reload={() => loadData(true)}` laedt still nach - ohne Ladeanzeige, damit ein
Speichern im Dialog nicht die ganze Liste flackern laesst.
`reload={() => loadData(true)}` lädt still nach - ohne Ladeanzeige, damit ein
Speichern im Dialog nicht die ganze Liste flackern lässt.
## Komponentenkarte
@@ -127,8 +127,8 @@ src/
## Dark Mode ohne `dark:`-Varianten
Die Klasse `dark` auf `<html>` tauscht in `src/index.css` nur die Werte der
Variablen `canvas`, `surface`, `ink-*`, `brand-*` und der getoenten Statusfarben.
Die Komponenten benutzen durchgaengig diese Skalen und folgen damit automatisch -
Variablen `canvas`, `surface`, `ink-*`, `brand-*` und der getönten Statusfarben.
Die Komponenten benutzen durchgängig diese Skalen und folgen damit automatisch -
kein `dark:` im Markup.
```mermaid
@@ -143,9 +143,9 @@ flowchart LR
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
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
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.
+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`.
`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`.
## Dienste auf dem Server
@@ -34,14 +34,14 @@ Das Passwort kommt aus `$env:PERINCO_CRM_SSH_PASSWORD` oder per `-Password`.
| Unit | Verzeichnis | Port | Anmerkung |
| --- | --- | --- | --- |
| `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 `.env` immer in Anfuehrungszeichen setzen - systemd liest `.env` als
Der Verzeichnisname der alten App enthält ein **Leerzeichen**. In Skripten und in
der `.env` immer in Anführungszeichen setzen - systemd liest `.env` als
`EnvironmentFile` und trennt sonst am Leerzeichen.
`MemoryMax=1536M` statt der urspruenglichen 512M: ein Scan-Lauf startet Chromium
als Kindprozess in derselben cgroup. Mit 512M wuerde der OOM-Killer das Backend
`MemoryMax=1536M` statt der ursprünglichen 512M: ein Scan-Lauf startet Chromium
als Kindprozess in derselben cgroup. Mit 512M würde der OOM-Killer das Backend
mitten im Scrapen beenden.
## Umgebungsvariablen
@@ -50,21 +50,21 @@ mitten im Scrapen beenden.
| 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` |
| `JWT_SECRET`, `JWT_EXPIRES_IN` | Signatur der Sitzungen, Standard 12 h |
| `ADMIN_*` | wird beim ersten Start angelegt, nur wenn `crm_users` leer ist |
| `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_SCHEDULER` | Hintergrund-Scraper, Standard an, nur `false` schaltet ab |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den ersten Lauf, Standard 30 |
| `LEGACY_*` | Quelle fuer `npm run scanner:import` |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz für den ersten Lauf, Standard 30 |
| `LEGACY_*` | Quelle für `npm run scanner:import` |
| `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | App-only-Zugriff auf Microsoft Graph |
| `MS_MAILBOX` | abzugleichendes Postfach, Standard `crm@perinco.de` |
| `MS_MAIL_FOLDER` | Ordner, Standard `inbox` |
| `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
(`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
Exchange Online: [11 Postfachanbindung](11-postfach.md).
> Die `MS_*`-Werte werden **beim Start** gelesen. Nach einer Aenderung an der
> `.env` muss der Dienst neu starten, ein Speichern in der Oberflaeche genuegt
> Die `MS_*`-Werte werden **beim Start** gelesen. Nach einer Änderung an der
> `.env` muss der Dienst neu starten, ein Speichern in der Oberfläche genügt
> nicht - dort liegen nur Intervall, Schalter und Zeitraum (`crm_settings`).
## Playwright
`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
faellt kein zweiter Browser-Download an. Ein `^` in der Version wuerde das
aufgeben; die Begruendung steht als `comments`-Feld in `server/package.json`.
fällt kein zweiter Browser-Download an. Ein `^` in der Version würde das
aufgeben; die Begründung steht als `comments`-Feld in `server/package.json`.
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
> 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
> gestartet, den der folgende Restart mitten im Scrapen beendet hat. Harmlos, aber
> vermeidbar: erst neu starten, dann importieren.
## Stoerungssuche
## Störungssuche
| 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 |
| Kein Treffer trotz Scan | `scanning_active`, aktive Agenten mit CV, OpenAI-Key gesetzt |
| 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` |
| 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 |
| 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
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
```
Die `.env` gehoert **nicht** ins Repository, aber in die Sicherung - ohne
`SCANNER_ENCRYPTION_KEY` sind die Portal-Passwoerter verloren. Genau das ist der
Die `.env` gehört **nicht** ins Repository, aber in die Sicherung - ohne
`SCANNER_ENCRYPTION_KEY` sind die Portal-Passwörter verloren. Genau das ist der
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
Anwendung tut und wie man sie bedient.
## 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 |
| --- | --- |
| 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** |
| 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
`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,
welches von beidem nicht stimmte - so kann ueber die Anmeldemaske niemand
welches von beidem nicht stimmte - so kann über die Anmeldemaske niemand
Benutzernamen durchprobieren.
Es gibt zwei Rollen: **Administrator** sieht zusaetzlich die Nutzerverwaltung und
die Scanner-Einstellungen. Alles andere ist fuer beide Rollen gleich.
Es gibt zwei Rollen: **Administrator** sieht zusätzlich die Nutzerverwaltung und
die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
## Sich zurechtfinden
@@ -33,13 +33,15 @@ die Scanner-Einstellungen. Alles andere ist fuer beide Rollen gleich.
│ Kategorien │ Titel der Ansicht [Hell/Dunkel] │
│ Opportunities├──────────────────────────────────────────┤
│ Kalender │ │
│ Kontakte │ Inhalt │
│ Kontakte ▾ │ Inhalt │
│ Alle Kont. │ │
│ Aus Mails 5│ │
│ Mails │ │
│ Scanner ▾ │ │
│ Dashboard │ │
│ Unpassend │ │
│ Suchagenten│ │
│ Boersen-… │ │
│ Börsen-… │ │
├──────────────┤ │
│ 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.
* Rechts oben schaltet zwischen hellem und dunklem Design. Ein **Doppelklick**
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
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:
| Ansicht | wofuer |
| Ansicht | wofür |
| --- | --- |
| **Kachel** | Ueberblick mit Volumen, Tagessatz, Laufzeit, letzter Interaktion |
| **Liste** | viele Eintraege vergleichen, Spaltenkoepfe sortieren |
| **Kachel** | Überblick mit Volumen, Tagessatz, Laufzeit, letzter Interaktion |
| **Liste** | viele Einträge vergleichen, Spaltenköpfe sortieren |
| **Pipeline** | Fortschritt durch die Phasen, Karten per Maus verschieben |
**Volumen** rechnet die Anwendung selbst: Tagessatz x Personentage. Das
**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.
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", …).
Aendern und loeschen darf der **Ersteller**, der **Zustaendige** und jeder
**Administrator**. Wer keines davon ist, bekommt eine Fehlermeldung - das prueft
der Server, nicht nur die Oberflaeche.
Ändern und löschen darf der **Ersteller**, der **Zuständige** und jeder
**Administrator**. Wer keines davon ist, bekommt eine Fehlermeldung - das prüft
der Server, nicht nur die Oberfläche.
Auf dem Tablet oder ohne Lust auf Ziehen: die Phase laesst sich auch ueber das
Auswahlfeld direkt auf der Karte aendern.
Auf dem Tablet oder ohne Lust auf Ziehen: die Phase lässt sich auch über das
Auswahlfeld direkt auf der Karte ändern.
## Kalender
Ein **gemeinsamer** Teamkalender - alle sehen dieselben Termine, jeder darf
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 Wochentagskopf springt in die Tagesansicht.
* **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
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
darf nur, wer den Termin auch aendern darf.
* **Farben** gehoeren zum Ersteller. Die eigene Farbe aendert man unter
darf nur, wer den Termin auch ändern darf.
* **Farben** gehören zum Ersteller. Die eigene Farbe ändert man unter
*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.
* **@-Erwaehnungen**: In der Beschreibung `@` tippen, dann Kontakte *und*
Benutzer aus der Vorschlagsliste waehlen (Pfeiltasten, Enter). In der
Leseansicht sind sie klickbar - Kontakte oeffnen die Kontaktkarte, Benutzer ein
Detailfenster mit naechsten Terminen.
* Rechts stehen die **naechsten Termine**, nach Tagen gruppiert. Vergangenes
* **@-Erwähnungen**: In der Beschreibung `@` tippen, dann Kontakte *und*
Benutzer aus der Vorschlagsliste wählen (Pfeiltasten, Enter). In der
Leseansicht sind sie klickbar - Kontakte öffnen die Kontaktkarte, Benutzer ein
Detailfenster mit nächsten Terminen.
* Rechts stehen die **nächsten Termine**, nach Tagen gruppiert. Vergangenes
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.
## Kontakte
Drei Ansichten, davon eine besondere:
| Ansicht | wofuer |
| Ansicht | wofür |
| --- | --- |
| **Kachel** | Ueberblick mit Foto, Firma, Status |
| **Liste** | sortierbar, viele Eintraege |
| **Kachel** | Überblick mit Foto, Firma, Status |
| **Liste** | sortierbar, viele Einträge |
| **Netzwerk** | wer steht mit wem in Kontakt |
Im **Netzwerk** sind Kreise Kontakte (Farbe = Status, Groesse = Anzahl
Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Gruene Linien
Im **Netzwerk** sind Kreise Kontakte (Farbe = Status, Größe = Anzahl
Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien
verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem
Kontakt.
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.
Suche und Statusfilter wirken auch hier.
Sobald Kontakte aus dem Postfach uebernommen wurden, erscheint neben dem
Statusfilter ein **Herkunftsfilter**: *Aus Mails uebernommen* zeigt nur die aus
Sobald Kontakte aus dem Postfach übernommen wurden, erscheint neben dem
Statusfilter ein **Herkunftsfilter**: *Aus Mails übernommen* zeigt nur die aus
dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten.
### Aus Mails
Der Menuepunkt *Kontakte* klappt zwei Unterpunkte auf: *Alle Kontakte* und
*Aus Mails*. Der Zaehler am Menuepunkt sagt, wie viele Funde warten.
Der Menüpunkt *Kontakte* klappt zwei Unterpunkte auf: *Alle Kontakte* und
*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
hat. **Sie sind noch keine Kontakte**: sie erscheinen nicht in der Kontaktliste,
nicht in Opportunities und nicht im Kalender. Jede Karte zeigt alle gefundenen
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".
* **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
durchgestrichen, was bisher im Kontakt stand. *Kontakt aktualisieren* oeffnet
die Feldauswahl: angehakt sind zunaechst nur die Luecken, also Felder, die im
Kontakt leer sind. Ein Wert, der etwas Gepflegtes ersetzen wuerde, ist als
*ueberschreibt* gekennzeichnet und muss bewusst angehakt werden - die Angabe
durchgestrichen, was bisher im Kontakt stand. *Kontakt aktualisieren* öffnet
die Feldauswahl: angehakt sind zunächst nur die Lücken, also Felder, die im
Kontakt leer sind. Ein Wert, der etwas Gepflegtes ersetzen würde, ist als
*überschreibt* gekennzeichnet und muss bewusst angehakt werden - die Angabe
kann aus einer alten Signatur stammen. *Neu anlegen* erzeugt trotz Dublette
einen zweiten Kontakt, etwa wenn es zwei Personen mit demselben Namen gibt.
Weitere Bedienelemente:
* **Alle neuen übernehmen** legt alle weissen Karten auf einmal als Kontakte an;
gold markierte bleiben unberuehrt.
* **Alle neuen übernehmen** legt alle weißen Karten auf einmal als Kontakte an;
gold markierte bleiben unberührt.
* **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
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.
## Mails
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
markiert, nichts geloescht. Antworten geht weiter in Outlook; der Link *In Outlook
oeffnen* springt direkt zur Mail.
**nur lesend** - im Postfach selbst wird nichts verändert, keine Mail als gelesen
markiert, nichts gelöscht. Antworten geht weiter in Outlook; der Link *In Outlook
öffnen* springt direkt zur Mail.
Zwei Reiter oben:
### 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:
| Kennzeichen | Bedeutung |
| --- | --- |
| **1x zugeordnet** | haengt an einer Opportunity oder einem Kontakt |
| **2 Kontakte** | so viele Kontakt-Vorschlaege warten noch auf eine Entscheidung |
| 📎 | Anhaenge vorhanden (im Postfach, nicht im CRM) |
| **1x zugeordnet** | hängt an einer Opportunity oder einem Kontakt |
| **2 Kontakte** | so viele Kontakt-Vorschläge warten noch auf eine Entscheidung |
| 📎 | Anhänge vorhanden (im Postfach, nicht im CRM) |
Das Auswahlfeld filtert die Liste. **Nicht zugeordnet** ist die eigentliche
Arbeitsliste: alles, was noch nirgends haengt. Die Suche geht ueber Betreff,
Absender und den kompletten Mailtext; mehrere Woerter wirken als ein Suchbegriff.
Arbeitsliste: alles, was noch nirgends hängt. Die Suche geht über Betreff,
Absender und den kompletten Mailtext; mehrere Wörter wirken als ein Suchbegriff.
**Zuordnen** haengt die Mail an eine Opportunity und/oder einen Kontakt. Der
Dialog schlaegt oben vor, was zu den Mailadressen passt - ein Klick auf den
Vorschlag genuegt. Beides gleichzeitig ist moeglich, jede Zuordnung laesst sich
einzeln wieder mit dem **x** loesen.
**Zuordnen** hängt die Mail an eine Opportunity und/oder einen Kontakt. Der
Dialog schlägt oben vor, was zu den Mailadressen passt - ein Klick auf den
Vorschlag genügt. Beides gleichzeitig ist möglich, jede Zuordnung lässt sich
einzeln wieder mit dem **x** lösen.
Beim Mailinhalt sind externe Bilder zunaechst gesperrt. Das ist Absicht: geladene
Bilder melden dem Absender, dass die Mail geoeffnet wurde. Ueber *Externe Bilder
laden* geht es pro Mail. *Groesser* klappt den Inhalt auf, *Nur Text anzeigen*
Beim Mailinhalt sind externe Bilder zunächst gesperrt. Das ist Absicht: geladene
Bilder melden dem Absender, dass die Mail geöffnet wurde. Über *Externe Bilder
laden* geht es pro Mail. *Größer* klappt den Inhalt auf, *Nur Text anzeigen*
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.
### 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
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
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*,
*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
„Mail", nicht nur ein Eintrag in *Aus Mails*: hier hat ja schon jemand
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
ist, bleibt unangetastet.
* **Verwerfen** - der Vorschlag verschwindet aus der Liste.
@@ -227,18 +231,18 @@ Zwei Knoepfe:
Eigene Kollegen (`@perinco.de`) und Funktionsadressen wie `info@` oder
`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.
## 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.
### Dashboard
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:
@@ -246,123 +250,123 @@ Die Filterzeile:
| --- | --- |
| **Zeitraum** | wie alt darf das Projekt sein (Standard: letzte 24 Stunden) |
| **Agent** | nur Treffer eines Beraters |
| **Anzeige ab %** | schwaechere Treffer ausblenden |
| **Anzeige ab %** | schwächere Treffer ausblenden |
| **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`.
Ein Klick auf eine Zeile oeffnet die Details: Projektbeschreibung, die
KI-Auswertung mit Pro und Contra, dazu die Knoepfe *Gesehen*, *Beworben*,
*Zurueck auf Neu*, *Boerse oeffnen*, *Passt nicht* und *Treffer loeschen*.
Ein Klick auf eine Zeile öffnet die Details: Projektbeschreibung, die
KI-Auswertung mit Pro und Contra, dazu die Knöpfe *Gesehen*, *Beworben*,
*Zurück auf Neu*, *Börse öffnen*, *Passt nicht* und *Treffer löschen*.
Ueber der Liste steht der Betriebszustand: *automatische Suche aktiv*, *nur
manuelle Suche*, *pausiert* oder *Suchlauf laeuft*.
Über der Liste steht der Betriebszustand: *automatische Suche aktiv*, *nur
manuelle Suche*, *pausiert* oder *Suchlauf läuft*.
### 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
wissen:
1. **Nur im Dashboard.** Wer auf Opportunities oder Kontakte wechselt, hoert
nichts - und bekommt es auch spaeter nicht nachgemeldet.
2. **In einem anderen Browsertab** klingelt es weiter, aber verzoegert: der
Browser bremst versteckte Tabs auf etwa eine Pruefung pro Minute. Ausserdem
muss man vorher einmal irgendwo auf der Seite geklickt haben, sonst laesst der
1. **Nur im Dashboard.** Wer auf Opportunities oder Kontakte wechselt, hört
nichts - und bekommt es auch später nicht nachgemeldet.
2. **In einem anderen Browsertab** klingelt es weiter, aber verzögert: der
Browser bremst versteckte Tabs auf etwa eine Prüfung pro Minute. Ausserdem
muss man vorher einmal irgendwo auf der Seite geklickt haben, sonst lässt der
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.
### Unpassend
Alles, was ueber *Passt nicht* aussortiert wurde. Von hier bringt *Zurueck ins
Dashboard* einen Treffer wieder zurueck, oder er wird endgueltig geloescht.
Alles, was über *Passt nicht* aussortiert wurde. Von hier bringt *Zurück ins
Dashboard* einen Treffer wieder zurück, oder er wird endgültig gelöscht.
### Suchagenten
Ein Suchagent ist ein Berater:
| Feld | wofuer |
| Feld | wofür |
| --- | --- |
| **Name** | erscheint an jedem Treffer |
| **E-Mail** | Ziel der Benachrichtigung fuer diesen Berater |
| **Suchbegriffe** | kommagetrennt, damit wird auf den Boersen gesucht |
| **Ausschluss-Begriffe** | kommagetrennt; Projekte mit diesen Woertern werden uebersprungen |
| **E-Mail** | Ziel der Benachrichtigung für diesen Berater |
| **Suchbegriffe** | kommagetrennt, damit wird auf den Börsen gesucht |
| **Ausschluss-Begriffe** | kommagetrennt; Projekte mit diesen Wörtern werden übersprungen |
| **Lebenslauf (PDF)** | wird als Text ausgelesen und gegen jedes Projekt bewertet |
| **aktiv** | nur aktive Agenten suchen im Hintergrund |
Ohne Lebenslauf kann ein Agent nichts bewerten. Die Ausschluss-Begriffe sind
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.
> **Loeschen nimmt die Treffer mit.** Wird ein Suchagent geloescht, verschwinden
> alle seine bisherigen Treffer und sein Lebenslauf mit ihm - die Datenbank raeumt
> das automatisch auf. Die gefundenen **Projekte** bleiben erhalten, sie gehoeren
> **Löschen nimmt die Treffer mit.** Wird ein Suchagent gelöscht, verschwinden
> alle seine bisherigen Treffer und sein Lebenslauf mit ihm - die Datenbank räumt
> das automatisch auf. Die gefundenen **Projekte** bleiben erhalten, sie gehören
> 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.
### 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.
* **Geschuetzte Portale** (Freelance.de, Freelancermap.de) brauchen eine
angemeldete Sitzung. Dafuer exportiert man die Cookies mit der
Browser-Erweiterung *Cookie-Editor* als JSON und fuegt sie unter
* **Öffentliche Portale** (Hays, Etengo, …) brauchen keinen Login.
* **Geschützte Portale** (Freelance.de, Freelancermap.de) brauchen eine
angemeldete Sitzung. Dafür exportiert man die Cookies mit der
Browser-Erweiterung *Cookie-Editor* als JSON und fügt sie unter
**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.
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.
## Einstellungen
| Reiter | Wer | Inhalt |
| --- | --- | --- |
| **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, loeschen |
| **Mein Konto** | alle | Foto, Kalenderfarbe, Passwort aendern |
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktuebernahme |
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schluessel, Ton, E-Mail |
| **Whisper** | alle | Spracherkennung fuer das Diktat einrichten |
| **Nutzerverwaltung** | Admin | Benutzer anlegen, bearbeiten, deaktivieren, löschen |
| **Mein Konto** | alle | Foto, Kalenderfarbe, Passwort ändern |
| **Postfach** | Admin | Verbindung zu Microsoft 365, Abgleich, Kontaktübernahme |
| **Scanner** | Admin | Suchintervall, Schwellenwerte, KI-Schlüssel, Ton, E-Mail |
| **Whisper** | alle | Spracherkennung für das Diktat einrichten |
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
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.
**Verbindung testen** prueft beides: die Anmeldung *und* den tatsaechlichen
Zugriff auf `crm@perinco.de`. Schlaegt etwas fehl, nennt die Meldung den
**Verbindung testen** prüft beides: die Anmeldung *und* den tatsächlichen
Zugriff auf `crm@perinco.de`. Schlägt etwas fehl, nennt die Meldung den
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
welcher.
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.
## Diktat
Ueberall, wo laengerer Text entsteht, sitzt ein Mikrofon-Knopf. Beim ersten Mal
steht dort *Diktat einrichten* und fuehrt in die Einstellungen: dort einmal
Überall, wo längerer Text entsteht, sitzt ein Mikrofon-Knopf. Beim ersten Mal
steht dort *Diktat einrichten* und führt in die Einstellungen: dort einmal
**Sprachmodell laden** klicken, rund 759 MB, danach ist es dauerhaft in diesem
Browser.
Die Erkennung laeuft **komplett auf dem eigenen Rechner**. Aufnahmen verlassen
das Geraet nicht und werden nirgends gespeichert. Auf einem anderen Rechner oder
in einem anderen Browser faellt der Download erneut an - er gehoert zum Browser,
Die Erkennung läuft **komplett auf dem eigenen Rechner**. Aufnahmen verlassen
das Gerät nicht und werden nirgends gespeichert. Auf einem anderen Rechner oder
in einem anderen Browser fällt der Download erneut an - er gehört zum Browser,
nicht zum Benutzerkonto.
## Wenn etwas nicht klappt
@@ -373,10 +377,10 @@ nicht zum Benutzerkonto.
| 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? |
| Mikrofon-Knopf fehlt | kein Mikrofon oder keine HTTPS-Verbindung |
| Boerse zeigt „Abgelaufen" | Cookies neu importieren |
| „Ein Suchlauf laeuft bereits" | kurz warten, es laeuft nur einer zur Zeit |
| Aendern nicht erlaubt | bei Opportunities darf nur Ersteller, Zustaendiger oder Admin |
| Börse zeigt „Abgelaufen" | Cookies neu importieren |
| „Ein Suchlauf läuft bereits" | kurz warten, es läuft nur einer zur Zeit |
| Ä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 |
| „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 |
| 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
startet, wo man was aendert - und welche Fallen es gibt.
Für jemanden, der das Projekt zum ersten Mal öffnet. Was man braucht, wie man es
startet, wo man was ändert - und welche Fallen es gibt.
## In zwei Minuten zum Bild
* **Ein** Vite-Frontend (React 19, TypeScript, Tailwind 4), **ein** Express-Backend,
**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.
* 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
uebernommen wurde.
* Alles auf Deutsch: Bezeichner, Kommentare, Meldungen. Kommentare erklaeren das
übernommen wurde.
* Alles auf Deutsch: Bezeichner, Kommentare, Meldungen. Kommentare erklären das
**Warum**, nicht das Was.
## Voraussetzungen
| | 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 |
| 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
@@ -45,10 +45,10 @@ Was dabei von selbst passiert:
* Fehlt `ADMIN_PASSWORD`, wird beim ersten Start ein Zufallspasswort erzeugt und
**einmalig ins Log geschrieben**. Dort nachsehen, sonst kommt man nicht rein.
Ohne MariaDB startet das Backend nicht. Das Frontend laesst sich trotzdem
ansehen - siehe *Oberflaeche pruefen ohne Datenbank* weiter unten.
Ohne MariaDB startet das Backend nicht. Das Frontend lässt sich trotzdem
ansehen - siehe *Oberfläche prüfen ohne Datenbank* weiter unten.
## Pruefen
## Prüfen
```bash
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
```
Automatische Tests gibt es nur fuer **einen** Bereich: `server/test/mail.test.mjs`
prueft die Kontaktextraktion aus Mails und den Graph-Client. Beides laeuft
unbeaufsichtigt im Hintergrund und laesst sich von Hand kaum nachstellen - ein
Automatische Tests gibt es nur für **einen** Bereich: `server/test/mail.test.mjs`
prüft die Kontaktextraktion aus Mails und den Graph-Client. Beides läuft
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
Dienst kommt genau dann, wenn niemand hinsieht. Kein Test-Framework, reines Node,
keine Datenbank.
Fuer alles andere gilt: `tsc`, ein Build und ein Blick in die Oberflaeche - notfalls
mit dem Stub aus [Oberflaeche pruefen ohne Datenbank](#oberflaeche-pruefen-ohne-datenbank).
Für alles andere gilt: `tsc`, ein Build und ein Blick in die Oberfläche - notfalls
mit dem Stub aus [Oberfläche prüfen ohne Datenbank](#oberflaeche-pruefen-ohne-datenbank).
## Aufbau
@@ -86,7 +86,7 @@ docs/ diese Dokumentation
deploy/ Apache-Vhosts, systemd-Unit, Deployskript
```
## Wie eine Anfrage laeuft
## Wie eine Anfrage läuft
```mermaid
sequenceDiagram
@@ -109,25 +109,25 @@ sequenceDiagram
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
danach nichts nachladen.
## Etwas hinzufuegen
## Etwas hinzufügen
### Neues Feld an einem Kontakt
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
`INSERT`/`UPDATE` und in den `SELECT` aufnehmen.
3. `src/types.ts`: Feld ins `Lead`-Interface.
4. `src/components/leads/LeadModal.tsx`: Eingabefeld, `inputClass` benutzen.
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).
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
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`.
2. `src/components/settings/Settings.tsx`: Eintrag in `TABS` und Rendering.
@@ -147,24 +147,24 @@ Ein Sprung dorthin aus einer beliebigen Tiefe:
### Neuer Endpunkt
1. Router in `server/src/routes/` anlegen, `router.use(requireAuth)` nicht
vergessen, `requireAdmin` wo noetig.
2. In `server/src/server.js` einhaengen - **vor** dem `/api`-404-Fallback.
3. In `src/lib/api.ts` eine typisierte Funktion ergaenzen.
vergessen, `requireAdmin` wo nötig.
2. In `server/src/server.js` einhängen - **vor** dem `/api`-404-Fallback.
3. In `src/lib/api.ts` eine typisierte Funktion ergänzen.
### 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
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
Positivliste des Speicher-Endpunkts.
2. Lesen mit `getSettings(defaults)` aus `server/src/settings.js`, schreiben mit
`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.
## Fallen
@@ -173,26 +173,26 @@ Diese Punkte haben real Zeit gekostet:
| 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. |
| **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. |
| **`.env` ist gleichzeitig `EnvironmentFile`** | Also kein `export`, keine Shell-Ausdruecke, Werte mit Leerzeichen in Anfuehrungszeichen. |
| **Zeitstempel koennen nur vorziehen** | Ein „vorgealterter" `lastRunTime` verzoegert keinen Cronlauf. Wer verzoegern 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. |
| **`.env` ist gleichzeitig `EnvironmentFile`** | Also kein `export`, keine Shell-Ausdrücke, Werte mit Leerzeichen in Anführungszeichen. |
| **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** | 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. |
| **Kaskaden beim Loeschen** | Einen Suchagenten loeschen loescht 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. |
| **`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. |
| **`/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. |
| **Kaskaden beim Löschen** | Einen Suchagenten löschen löscht alle seine Treffer mit. Inaktiv setzen ist meist gemeint. |
| **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 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 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. |
| **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,
der `dist/` ausliefert und auf `/api/...` erfundene Daten zurueckgibt. Token vorab
setzen, dann ist die Anmeldung uebersprungen:
Für Bildschirmfotos und Klickwege genügt ein Stub: ein kleiner `node:http`-Server,
der `dist/` ausliefert und auf `/api/...` erfundene Daten zurückgibt. Token vorab
setzen, dann ist die Anmeldung übersprungen:
```js
await page.goto(BASE);
@@ -208,18 +208,18 @@ Browser-Download.
* **Deutsch** in Bezeichnern, Kommentaren, Meldungen. Fachbegriffe bleiben
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`,
`surface` benutzen, den Rest macht `index.css`.
* **Gemeinsame Klassen** statt kopierter Utility-Ketten: `inputClass`,
`labelClass` aus `components/leads/leadUi.tsx`, dazu `cardClass`,
`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()`.
* **Rechte immer doppelt**: Oberflaeche blendet aus, Backend antwortet mit 403.
* **Rechte immer doppelt**: Oberfläche blendet aus, Backend antwortet mit 403.
## Deployment
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.
+68 -68
View File
@@ -1,6 +1,6 @@
# 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.
## Backend
@@ -11,16 +11,16 @@ Baut die Express-App. Die **Reihenfolge** ist bedeutsam:
```mermaid
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"]
H --> R["/api/auth, /users, /leads,<br/>/connections, /events, /opportunities"]
R --> S["/api/scanner (requireAuth + Router)"]
S --> F["/api → 404 JSON<br/>faengt alles Unbekannte"]
F --> E["Fehlerbehandlung:<br/>400 kaputtes JSON, 413 zu gross, 500 Rest"]
S --> F["/api → 404 JSON<br/>fängt alles Unbekannte"]
F --> E["Fehlerbehandlung:<br/>400 kaputtes JSON, 413 zu groß, 500 Rest"]
```
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'`.
### `server/src/db.js`
@@ -28,11 +28,11 @@ Beim Start: `initDatabase()` (CRM-Schema, Admin-Seed) → `initScannerDatabase()
| Export | Zweck |
| --- | --- |
| `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
`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.
`ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne
@@ -44,19 +44,19 @@ im Quelltext steht kein Passwort.
| Export | Zweck |
| --- | --- |
| `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'` |
Dass `requireAuth` bei jeder Anfrage die Datenbank liest, ist Absicht: ein
deaktivierter oder geloeschter Benutzer ist sofort draussen und nicht erst, wenn
sein Token ablaeuft. `secret()` wirft, wenn `JWT_SECRET` fehlt oder kuerzer als 16
deaktivierter oder gelöschter Benutzer ist sofort draußen und nicht erst, wenn
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.
### `server/src/userFields.js`
`normalizeColor` und `normalizeAvatar`, benutzt von `routes/auth.js` und
`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
@@ -65,33 +65,33 @@ Alle unter `router.use(requireAuth)`.
| Router | Endpunkte | Rechte |
| --- | --- | --- |
| `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 |
| `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 |
| `opportunities.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | aendern/loeschen nur Ersteller, Zustaendiger 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` | ä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 |
Wiederkehrende Muster in diesen Routern:
* `emptyToNull()` - leere Zeichenketten werden `NULL`, damit „nicht gesetzt" und
„leer" in der Datenbank nicht zwei Zustaende sind.
* `normalizeDate()` prueft echte Kalendertage; der 31. Februar wird abgelehnt und
„leer" in der Datenbank nicht zwei Zustände sind.
* `normalizeDate()` prüft echte Kalendertage; der 31. Februar wird abgelehnt und
nicht stillschweigend verschoben.
* `normalizeNumber()` akzeptiert Komma als Dezimaltrennzeichen und begrenzt nach
oben - ein Tagessatz von 10 Millionen ist ein Tippfehler.
* `parseTags()` liest die als Text gespeicherten JSON-Arrays defensiv: kaputter
Inhalt ergibt eine leere Liste, keinen Absturz.
* `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
geloescht werden.
gelöscht werden.
### `server/src/settings.js`
`getSetting`, `getSettings`, `setSetting` auf `crm_settings`, dazu `asBool` und
`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.
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()` |
| `extract.js` | `extractCandidates()`, `parsePersonName()`, `parseSignature()`, `AUTO_ADOPT_MIN_CONFIDENCE` |
| `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 |
| `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:
* `graphRequest()` nimmt Pfade **und** vollstaendige URLs - Delta-Links kommen
absolut von Graph zurueck.
* Ein `401` verwirft das Token genau einmal und wiederholt; so ueberlebt ein Lauf
ein serverseitig zurueckgezogenes Token.
* `upsertMessage()` laesst die Textspalten unberuehrt, wenn `bodyText` im
uebergebenen Objekt `undefined` ist. Ein reines Metadaten-Update darf den schon
geholten Mailtext nicht loeschen.
* `graphRequest()` nimmt Pfade **und** vollständige URLs - Delta-Links kommen
absolut von Graph zurück.
* Ein `401` verwirft das Token genau einmal und wiederholt; so überlebt ein Lauf
ein serverseitig zurückgezogenes Token.
* `upsertMessage()` lässt die Textspalten unberührt, wenn `bodyText` im
übergebenen Objekt `undefined` ist. Ein reines Metadaten-Update darf den schon
geholten Mailtext nicht löschen.
* `extractCandidates()` verlangt im Fliesstext eine Anrede vor dem Namen.
Ohne diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener
Woerter als Namen.
Ohne diese Bedingung liest der Musterabgleich jede Folge großgeschriebener
Wörter als Namen.
* 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`
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:
* `multer` schreibt CV-PDFs nach `UPLOAD_DIR`, Grenze 20 MB.
* `resolveCvPath()` findet eine Datei auch dann, wenn in der Datenbank noch ein
relativer Pfad aus der alten App steht - es zaehlt der Dateiname.
* `GET /agents` laesst `cvText` weg (mehrere Hundert KB pro Agent).
relativer Pfad aus der alten App steht - es zählt der Dateiname.
* `GET /agents` lässt `cvText` weg (mehrere Hundert KB pro Agent).
* `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.
* `normalizeCookie()` biegt Exporte von Cookie-Editor auf das zurecht, was
Playwright akzeptiert (`sameSite`, `expirationDate` → `expires`).
@@ -147,27 +147,27 @@ Die portierte Scanner-API unter `/api/scanner`, vollstaendig in
| Datei | Inhalt |
| --- | --- |
| `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/Agent.js` | `cvText`, `cvFilePath`, `keywords`, `excludeKeywords`, `isActive` |
| `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/Portal.js` | Zugangsdaten, `sessionState`, `connectionStatus` |
| `models/Setting.js` | Schluessel-Wert mit `getVal`/`setVal` |
| `services/scraper.js` | Playwright je Portal; unveraendert aus der alten App |
| `models/Setting.js` | Schlüssel-Wert mit `getVal`/`setVal` |
| `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/pipeline.js` | ein Lauf: scrapen, speichern, bewerten, benachrichtigen |
| `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 |
| `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()` |
| `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`
zurueck, wenn schon einer laeuft. `isPipelineRunning()` fragt den Zustand ab.
* `runMatchingPipeline()` läuft **nie zweimal gleichzeitig** und gibt `false`
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
Abbruch, damit sich ein gescheiterter Lauf nicht sofort wiederholt.
* `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 |
| `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` |
| `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
`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
Sequelize), inklusive der Grossbuchstaben `Agent` und `Project` an den Joins -
damit bleibt der Vergleich mit der alten App moeglich. Nur `ScannerProject`
traegt beide Schreibweisen fuer `created_at`, weil das Modell die Spalte selbst
damit bleibt der Vergleich mit der alten App möglich. Nur `ScannerProject`
trägt beide Schreibweisen für `created_at`, weil das Modell die Spalte selbst
umbenennt.
### `src/lib/mailApi.ts`
@@ -208,28 +208,28 @@ Typen (`MailMessageSummary`, `MailMessageDetail`, `MailCandidate`, `MailLink`,
`MailStatus`, `MailSyncResult`, `MailConnectionTest`, `MailSettingsValues`), das
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
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
Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
### Kontexte
| Datei | Haelt | Eigenheit |
| Datei | Hält | Eigenheit |
| --- | --- | --- |
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prueft beim Start das gespeicherte Token gegen `/auth/me` |
| `theme.tsx` | hell/dunkel/System | Wahl in `localStorage`, Doppelklick loest sie wieder |
| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` oeffnet den Kontakt direkt |
| `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker fuer die ganze App |
| `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 löst sie wieder |
| `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 für die ganze App |
### Whisper
| Datei | Inhalt |
| --- | --- |
| `whisper.tsx` | Konstanten (`WHISPER_MODEL`, `WHISPER_DTYPE`, `WHISPER_LANGUAGE`), Provider, Fristen, Haenger-Erkennung |
| `whisper.worker.ts` | `WorkerRequest`/`WorkerResponse`, laedt und erkennt |
| `whisperModels.ts` | `fetchModelSize()` holt die echte Groesse von Hugging Face |
| `whisper.tsx` | Konstanten (`WHISPER_MODEL`, `WHISPER_DTYPE`, `WHISPER_LANGUAGE`), Provider, Fristen, Hänger-Erkennung |
| `whisper.worker.ts` | `WorkerRequest`/`WorkerResponse`, lädt und erkennt |
| `whisperModels.ts` | `fetchModelSize()` holt die echte Größe von Hugging Face |
| `audio.ts` | `toWhisperAudio()` - Blob zu 16-kHz-Mono-PCM |
| `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/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs |
| `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Uebernehmen, Sammeluebernahme |
| `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergaenzen eines vorhandenen Kontakts |
| `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme |
| `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergänzen eines vorhandenen Kontakts |
| `components/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` |
| `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/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/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
| `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 |
| `USER_COLORS`, `userColor()` | Farbe gesetzt, sonst stabil aus der Benutzer-Id |
| `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 |
## Datenfluss im Frontend
@@ -278,16 +278,16 @@ flowchart TD
F -->|"still, ohne Ladeanzeige"| C
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
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
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.
Die fuenf CRM-Endpunkte laufen **parallel**, nicht nacheinander - sonst addieren
sich fuenf Rundreisen beim Anmelden. Die Scanner-Ansichten haengen bewusst nicht
Die fünf CRM-Endpunkte laufen **parallel**, nicht nacheinander - sonst addieren
sich fünf Rundreisen beim Anmelden. Die Scanner-Ansichten hängen bewusst nicht
an `loadData`: andere Datenbank, andere Auffrischung.
+34 -34
View File
@@ -1,64 +1,64 @@
# 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
Quelltext.
| Begriff | Bedeutung |
| --- | --- |
| **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". |
| **Benutzer** | Konto fuer 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`. |
| **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 für die Anmeldung, Rolle `admin` oder `user`. Tabelle `crm_users`. |
| **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. |
| **Cookie-Import** | Uebertragen 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. |
| **Kontakt** / Lead | Person im Netzwerk: Firma, Position, Status, Verbindungen. Tabelle `crm_leads` mit `mail_pool = 0`. Im Code durchgaengig `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. |
| **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 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 durchgängig `lead`. |
| **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. |
| **Kontakt-Vorschlag** | Aus einer Mail gelesene Person, die noch kein Kontakt ist. Tabelle `crm_mail_contact_candidates`. Wird uebernommen, ergaenzt 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. |
| **Opportunity** | Geschaeft in Arbeit: Phase, Tagessatz, Laufzeit, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. |
| **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 für **einen** Agenten: Prozentwert, Pro-Liste, Contra-Liste, Status. Tabelle `matches`. Ein Projekt erzeugt so viele Treffer, wie es aktive Agenten gibt. |
| **Opportunity** | Geschäft in Arbeit: Phase, Tagessatz, Laufzeit, Wahrscheinlichkeit, Ansprechpartner. Tabelle `crm_opportunities`. |
| **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`). |
| **Projekt** | Ein von einer Boerse gescrapter Auftrag. Tabelle `projects`, Aufbewahrung 7 Tage. |
| **Scraper** | Der Teil, der mit einem echten Browser (Playwright/Chromium) die Suchseiten der Boersen abruft. |
| **Session** (Portal) | Gespeicherte Cookies einer angemeldeten Boersen-Sitzung, im Playwright-Format `storageState`. |
| **Sitzung** (CRM) | Die eigene Anmeldung, als JWT im `localStorage`, 12 Stunden gueltig. |
| **Suchintervall** | Abstand zwischen zwei automatischen Suchlaeufen, in Minuten. Einstellung `search_interval`. |
| **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 Börsen abruft. |
| **Session** (Portal) | Gespeicherte Cookies einer angemeldeten Börsen-Sitzung, im Playwright-Format `storageState`. |
| **Sitzung** (CRM) | Die eigene Anmeldung, als JWT im `localStorage`, 12 Stunden gültig. |
| **Suchintervall** | Abstand zwischen zwei automatischen Suchläufen, in Minuten. Einstellung `search_interval`. |
| **Schwellenwert Anzeige** | `display_threshold` - Treffer darunter bleiben im Dashboard ausgeblendet. |
| **Schwellenwert 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`. |
| **Volumen** | Tagessatz x Personentage. **Gewichtet** heisst zusaetzlich mit der Abschlusswahrscheinlichkeit multipliziert. |
| **Whisper** | Das Spracherkennungsmodell fuer das Diktat. Laeuft 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. |
| **Zustaendiger** / Owner | Benutzer, dem ein Kontakt oder eine Opportunity zugeordnet ist. Darf sie aendern, auch wenn er sie nicht angelegt hat. |
| **Verbindung** | Kante zwischen zwei Kontakten im Netzwerkdiagramm, mit Art und Stärke. Tabelle `crm_lead_connections`. |
| **Volumen** | Tagessatz x Personentage. **Gewichtet** heißt zusätzlich mit der Abschlusswahrscheinlichkeit multipliziert. |
| **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 Postfächer - ohne sie gilt sie tenant-weit. |
| **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*. |
## 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 |
| `empfaenger` | Empfaenger | 78 |
| `absender` | Absender | 90, mit bestätigender Signatur 95 |
| `empfaenger` | Empfänger | 78 |
| `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
| Wert | in der Oberflaeche | Bedeutung |
| Wert | in der Oberfläche | Bedeutung |
| --- | --- | --- |
| `offen` | Offen | wartet auf eine Entscheidung |
| `uebernommen` | Uebernommen | 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 |
| `uebernommen` | Übernommen | es entstand ein Eintrag - im Automatiklauf in *Aus Mails*, von Hand ein Kontakt |
| `dublette` | Ergänzt | die Adresse war im Zielbestand vorhanden, der Eintrag wurde erweitert |
| `verworfen` | Verworfen | von Hand abgelehnt |
## 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 |
| `reviewed` | Gesehen | geprueft, noch keine Bewerbung |
| `reviewed` | Gesehen | geprüft, noch keine Bewerbung |
| `applied` | Beworben | Bewerbung ist raus |
| `rejected` | Abgelehnt | passt nicht; erscheint nur unter *Unpassend* |
@@ -66,7 +66,7 @@ Quelltext.
| Wert | Bedeutung |
| --- | --- |
| `connected` | Verbindung steht, letzte Pruefung erfolgreich |
| `expired` | Session abgelaufen oder CAPTCHA - neuer Cookie-Import faellig |
| `error` | Portal nicht erreichbar oder Seitenaufbau geaendert |
| `disconnected` | nie verbunden; bei oeffentlichen Portalen heisst das nur „bereit" |
| `connected` | Verbindung steht, letzte Prüfung erfolgreich |
| `expired` | Session abgelaufen oder CAPTCHA - neuer Cookie-Import fällig |
| `error` | Portal nicht erreichbar oder Seitenaufbau geändert |
| `disconnected` | nie verbunden; bei öffentlichen Portalen heißt das nur „bereit" |
+93 -93
View File
@@ -1,15 +1,15 @@
# 11 Postfachanbindung (Microsoft 365)
Das gemeinsame Vertriebspostfach `crm@perinco.de` wird **lesend** in das CRM
gespiegelt. Aus jeder Mail entstehen Kontakt-Vorschlaege, und jede Mail laesst
sich an eine Opportunity und an Kontakte haengen.
gespiegelt. Aus jeder Mail entstehen Kontakt-Vorschläge, und jede Mail lässt
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
[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
flowchart LR
@@ -25,7 +25,7 @@ flowchart LR
SY["mail/sync.js<br/>Delta Query"]
EX["mail/extract.js<br/>Kontakte lesen"]
ST["mail/store.js"]
CO["mail/contacts.js<br/>Uebernahme"]
CO["mail/contacts.js<br/>Übernahme"]
API["routes/mail.js<br/>/api/mail"]
end
@@ -57,7 +57,7 @@ Login.
2. **Client-Geheimnis** anlegen und den Wert sofort kopieren.
3. **API-Berechtigung** Microsoft Graph &rarr; *Anwendungsberechtigungen* &rarr;
`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
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
```
`Add-MailboxPermission` ist hier **wirkungslos** - das ist der Weg fuer
delegierten Zugriff bzw. EWS-Impersonation. Bei App-only zaehlt allein die
`Add-MailboxPermission` ist hier **wirkungslos** - das ist der Weg für
delegierten Zugriff bzw. EWS-Impersonation. Bei App-only zählt allein die
Zugriffsrichtlinie.
Der Server muss ausgehend `login.microsoftonline.com` und `graph.microsoft.com`
@@ -82,8 +82,8 @@ auf 443 erreichen.
## Umgebungsvariablen
Zugangsdaten stehen ausschliesslich in `server/.env` - nie in der Datenbank und
nie in einer API-Antwort. Ein Datenbank-Dump enthaelt damit keine Geheimnisse.
Zugangsdaten stehen ausschließlich in `server/.env` - nie in der Datenbank und
nie in einer API-Antwort. Ein Datenbank-Dump enthält damit keine Geheimnisse.
| Variable | Pflicht | Bedeutung |
| --- | --- | --- |
@@ -143,32 +143,32 @@ sequenceDiagram
Wesentliche Eigenschaften:
* **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
in Outlook oeffnet.
in Outlook öffnet.
* **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
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.
* **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.
* **Nur Metadaten in der Delta-Abfrage.** Der Mailtext wird einzeln nachgeholt
und nur, wenn er fehlt. Ein reines Metadaten-Update (etwa ein geaendertes
Gelesen-Kennzeichen) loescht den gespeicherten Text nicht.
und nur, wenn er fehlt. Ein reines Metadaten-Update (etwa ein geändertes
Gelesen-Kennzeichen) löscht den gespeicherten Text nicht.
* **Zeitgrenze doppelt.** Der Erstabgleich versucht
`$filter=receivedDateTime ge ...`; die Filterunterstuetzung in Delta-Abfragen
ist eng begrenzt, bei einem 400 laeuft er ohne Filter weiter. Der Filter ist
deshalb nur eine Ersparnis - geprueft wird die Grenze **immer** beim Speichern,
und zwar nur fuer noch unbekannte Mails. Ein an den ersten Lauf gebundener
Merker waere hier falsch: ein ungefilterter Erstabgleich zieht sich ueber
mehrere Laeufe, und in den folgenden kaeme der Rest des Postfachs trotz Grenze
`$filter=receivedDateTime ge ...`; die Filterunterstützung in Delta-Abfragen
ist eng begrenzt, bei einem 400 läuft er ohne Filter weiter. Der Filter ist
deshalb nur eine Ersparnis - geprüft wird die Grenze **immer** beim Speichern,
und zwar nur für noch unbekannte Mails. Ein an den ersten Lauf gebundener
Merker wäre hier falsch: ein ungefilterter Erstabgleich zieht sich über
mehrere Läufe, und in den folgenden käme der Rest des Postfachs trotz Grenze
herein.
Im Postfach geloeschte Mails werden mit `is_deleted = 1` gekennzeichnet, nicht
entfernt: eine an eine Opportunity gehaengte Mail soll nicht aus dem CRM
verschwinden, weil jemand sein Postfach aufraeumt.
Im Postfach gelöschte Mails werden mit `is_deleted = 1` gekennzeichnet, nicht
entfernt: eine an eine Opportunity gehängte Mail soll nicht aus dem CRM
verschwinden, weil jemand sein Postfach aufräumt.
## Kontakte aus Mails
@@ -178,7 +178,7 @@ nicht.
```mermaid
flowchart TD
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 --> T["Fliesstext<br/>Anrede + Name + Rufnummer"]
@@ -193,26 +193,26 @@ flowchart TD
C --> Z{"E-Mail vorhanden<br/>und Zuversicht &ge; 70?"}
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"]
```
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.
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.
Zwei Entscheidungen, die die Trefferqualitaet tragen:
Zwei Entscheidungen, die die Trefferqualität tragen:
* **Im Fliesstext ist eine Anrede Pflicht** („Herrn Max Mustermann Tel. …").
Ohne diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener
Woerter als Namen; „Zweiter Ansprechpartner" landete so als Kontakt. Ein
Ohne diese Bedingung liest der Musterabgleich jede Folge großgeschriebener
Wörter als Namen; „Zweiter Ansprechpartner" landete so als Kontakt. Ein
verpasster Treffer ist harmloser als ein falscher - eine Durchsichtsliste voll
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
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,
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
Menuepunkt *Kontakte*.
Menüpunkt *Kontakte*.
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
das Altsystem gescheitert. Deshalb sind Funde aus Mails Rohmaterial, bis jemand
entscheidet.
Eine Spalte statt einer zweiten Tabelle, weil `crm_mail_links` und
`crm_mail_contact_candidates` per Fremdschluessel auf `crm_leads` zeigen: der
Vorbereich behaelt damit seine Mailbezuege, ohne dass es zwei Wege gibt, eine
`crm_mail_contact_candidates` per Fremdschlüssel auf `crm_leads` zeigen: der
Vorbereich behält damit seine Mailbezüge, ohne dass es zwei Wege gibt, eine
Person mit einer Mail zu verbinden.
```mermaid
flowchart LR
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"]
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"]
P --> W["entfernen"]
```
Der Abgleich gegen die Kontakte laeuft im Browser (`mailPool.ts`): erst ueber die
E-Mail-Adresse, sonst ueber den vollen Namen. Telefonnummern werden dabei ueber
Der Abgleich gegen die Kontakte läuft im Browser (`mailPool.ts`): erst über die
E-Mail-Adresse, sonst über den vollen Namen. Telefonnummern werden dabei über
ihre Ziffern verglichen, damit „0221 123-4" und „02211234" nicht als Abweichung
erscheinen.
Beim Uebernehmen sind nur die **Luecken** vorausgewaehlt - Felder, in denen der
Kontakt noch nichts hat. Werte, die einen gepflegten Eintrag ersetzen wuerden,
sind als *ueberschreibt* gekennzeichnet und muessen bewusst angehakt werden: der
Beim Übernehmen sind nur die **Lücken** vorausgewählt - Felder, in denen der
Kontakt noch nichts hat. Werte, die einen gepflegten Eintrag ersetzen würden,
sind als *überschreibt* gekennzeichnet und müssen bewusst angehakt werden: der
Mailfund kann aus einer alten Signatur stammen.
Uebernommene Kontakte tragen weiterhin `crm_leads.source = 'Mail'` - die
Kontaktliste hat dafuer den Herkunftsfilter *Aus Mails uebernommen*.
Übernommene Kontakte tragen weiterhin `crm_leads.source = 'Mail'` - die
Kontaktliste hat dafür den Herkunftsfilter *Aus Mails übernommen*.
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
wanderten uebernommene Kontakte bei jedem Neustart zurueck.
wanderten übernommene Kontakte bei jedem Neustart zurück.
## 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
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
Origin der Anwendung und koennte `localStorage` lesen - also das Anmeldetoken.
Weil die Hoehe des Inhalts ohne dieses Flag nicht auslesbar ist, hat der Rahmen
eine feste Hoehe mit eigenem Rollbalken und einen Schalter „Groesser".
`allow-same-origin` wäre hier ein Fehler: ein `srcDoc`-Dokument erbt damit die
Origin der Anwendung und könnte `localStorage` lesen - also das Anmeldetoken.
Weil die Höhe des Inhalts ohne dieses Flag nicht auslesbar ist, hat der Rahmen
eine feste Höhe mit eigenem Rollbalken und einen Schalter „Größer".
Zusaetzlich entfernt das Backend beim Speichern Skripte, eingebettete Rahmen,
Formulare, Ereignis-Attribute und gefaehrliche URL-Schemata
Zusätzlich entfernt das Backend beim Speichern Skripte, eingebettete Rahmen,
Formulare, Ereignis-Attribute und gefährliche URL-Schemata
(`mail/html.js`, `sanitizeHtml`).
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.
## Endpunkte
@@ -287,79 +287,79 @@ Alle unter `/api/mail`, alle hinter `requireAuth`.
| 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/:id` | alle | Mail mit Text, Zuordnungen, Vorschlaegen |
| POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity haengen |
| DELETE | `/links/:id` | alle | Zuordnung loesen |
| GET | `/candidates` | alle | Kontakt-Vorschlaege, `status` waehlbar |
| POST | `/candidates/:id/adopt` | alle | Kontakt anlegen bzw. ergaenzen; `target: 'pool'` legt in „Aus Mails" ab |
| GET | `/messages/:id` | alle | Mail mit Text, Zuordnungen, Vorschlägen |
| POST | `/messages/:id/links` | alle | an Kontakt und/oder Opportunity hängen |
| DELETE | `/links/:id` | alle | Zuordnung lösen |
| GET | `/candidates` | alle | Kontakt-Vorschläge, `status` wählbar |
| POST | `/candidates/:id/adopt` | alle | Kontakt anlegen bzw. ergänzen; `target: 'pool'` legt in „Aus Mails" ab |
| 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`:
| Methode | Pfad | Zweck |
| --- | --- | --- |
| 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/:id/promote` | einen Eintrag uebernehmen; mit `target_lead_id` und `fields` in einen vorhandenen Kontakt |
| POST | `/leads/mail-pool/promote` | mehrere Einträge als neue Kontakte anlegen |
| 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
dort, sobald er als Kontakt gefuehrt wird. In beiden Faellen wandern die
Mailverknuepfungen mit an den Zielkontakt.
`keep_pool: true` behält den Eintrag in „Aus Mails"; ohne das verschwindet er
dort, sobald er als Kontakt geführt wird. In beiden Fällen wandern die
Mailverknüpfungen mit an den Zielkontakt.
| GET | `/settings` | admin | verstellbare Einstellungen |
| POST | `/settings` | admin | Einstellungen speichern |
| POST | `/test` | admin | Anmeldung **und** Postfachzugriff pruefen |
| POST | `/sync` | admin | Abgleich anstossen, `{"reset":true}` von vorn |
| POST | `/test` | admin | Anmeldung **und** Postfachzugriff prüfen |
| 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
vorbehalten.
`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
| Thema | KeepTrack | perinco CRM |
| --- | --- | --- |
| Fortschritt | `$filter=isRead eq false`, danach `PATCH isRead` | Delta Query mit gespeichertem `delta_link` |
| Berechtigung | Schreibzugriff noetig | `Mail.Read` genuegt |
| 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 |
| 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 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 |
| Kontakte ohne Adresse | Platzhalter `name@kontakt-aus-email.local` erfunden, um eine Datenbankbedingung zu erfuellen | Kontakt ohne Adresse; Vorschlag bleibt zur Durchsicht liegen |
| Vorhandener Kontakt | wurde uebersprungen | leere Felder werden ergaenzt, gepflegte bleiben |
| Kontaktdaten lesen | kompletter Mailtext an OpenAI, auch fuer 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 |
| Verbindungstest | prueft nur, ob ein Token kommt | prueft Token **und** Postfachzugriff, mit Hinweis je Fehlercode |
| 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 übersprungen | leere Felder werden ergänzt, gepflegte bleiben |
| 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`, lösbar, mehrfach möglich |
| 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 |
| 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 |
## Was noch nicht drin ist
* **Senden, Antworten, Weiterleiten.** Braucht `Mail.ReadWrite` und `Mail.Send`
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.
* **Automatische Zuordnung zu Opportunities.** Die Zuordnung erfolgt per Hand;
der Dialog schlaegt anhand der Mailadressen Kontakt und Opportunity vor. Eine
Bewertung durch ein Sprachmodell waere der naechste Schritt - dann bitte mit
der Dialog schlägt anhand der Mailadressen Kontakt und Opportunity vor. Eine
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.
* **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.
## Stoerungssuche
## Störungssuche
| 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 „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 |
| „Erstabgleich laeuft noch" bleibt stehen | Log auf `[Mail]` pruefen; `MAIL_MAX_PAGES` erhoehen, wenn es zu langsam vorangeht |
| Keine Kontakt-Vorschlaege | stehen alle Absender auf einer eigenen Domain? `mail_internal_domains` pruefen |
| Doppelte Kontakte | tritt nicht auf: Abgleich erfolgt ueber die Adresse in Kleinschreibung; bei Treffer wird ergaenzt 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 |
| „Erstabgleich läuft noch" bleibt stehen | Log auf `[Mail]` prüfen; `MAIL_MAX_PAGES` erhöhen, wenn es zu langsam vorangeht |
| Keine Kontakt-Vorschläge | stehen alle Absender auf einer eigenen Domain? `mail_internal_domains` prüfen |
| Doppelte Kontakte | tritt nicht auf: Abgleich erfolgt über die Adresse in Kleinschreibung; bei Treffer wird ergänzt statt angelegt |
| 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
werden von Gitea direkt gerendert.
**Neu hier?** Wer die Anwendung *benutzen* will, faengt bei
[07 Anleitung fuer Benutzer](07-anleitung-benutzer.md) an. Wer daran *entwickeln*
will, bei [08 Anleitung fuer Entwickler](08-anleitung-entwickler.md). Beide setzen
**Neu hier?** Wer die Anwendung *benutzen* will, fängt bei
[07 Anleitung für Benutzer](07-anleitung-benutzer.md) an. Wer daran *entwickeln*
will, bei [08 Anleitung für Entwickler](08-anleitung-entwickler.md). Beide setzen
keine Vorkenntnisse voraus.
| Kapitel | Inhalt |
| --- | --- |
| [01 Architektur](01-architektur.md) | Topologie, Prozesse, Ports, Anfrageweg, Rollen |
| [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 |
| [05 Frontend](05-frontend.md) | Ansichten, Menue, Komponentenkarte, Zustand |
| [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Stoerungssuche |
| [07 Anleitung fuer Benutzer](07-anleitung-benutzer.md) | Bedienung aller Bereiche, Schritt fuer Schritt |
| [08 Anleitung fuer Entwickler](08-anleitung-entwickler.md) | Einrichten, erweitern, bekannte Fallen |
| [05 Frontend](05-frontend.md) | Ansichten, Menü, Komponentenkarte, Zustand |
| [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Störungssuche |
| [07 Anleitung für Benutzer](07-anleitung-benutzer.md) | Bedienung aller Bereiche, Schritt für Schritt |
| [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 |
| [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
[README des Projekts](../README.md).
@@ -47,7 +47,7 @@ flowchart TB
end
OAI["OpenAI<br/>gpt-4o-mini"]
BOERSE["Projektboersen<br/>freelancermap, hays, ..."]
BOERSE["Projektbörsen<br/>freelancermap, hays, ..."]
GRAPH["Microsoft Graph<br/>crm@perinco.de"]
SPA -->|"HTTPS /api"| AP
@@ -70,5 +70,5 @@ flowchart TB
class ALT,DB3 alt
```
Gestrichelt: die urspruengliche Scanner-App, die bis zur Ablosung parallel
weiterlaeuft. Sie hat ihre eigene Datenbank und wird vom CRM nicht angefasst.
Gestrichelt: die ursprüngliche Scanner-App, die bis zur Ablosung parallel
weiterläuft. Sie hat ihre eigene Datenbank und wird vom CRM nicht angefasst.