feat: Projekt Scanner integrieren, Whisper in die Einstellungen

Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft
parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt
statt geteilt, damit die alte Installation unberuehrt bleibt:

- eigene Datenbank perinco_scanner, legt sich beim Start selbst an
- eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis
- eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig
  mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM)
- Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie,
  ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten

Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des
Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem
statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der
alten Datenbank, ohne sie zu veraendern.

Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit
aufklappbaren Unterpunkten, Einstellungen als eigener Reiter.

Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den
Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die
Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet
die Rolle.

Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf
1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische
Dokumentation mit Funktionszeichnungen unter docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-08-06 15:08:59 +02:00
co-authored by Claude Opus 5
commit 82d1b7eab4
106 changed files with 24133 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
# 01 Architektur
## Bausteine
Drei Teile, keiner davon spricht direkt mit einem anderen ausser ueber HTTP bzw.
SQL:
| Teil | Technik | Wo |
| --- | --- | --- |
| Frontend | React 19, TypeScript, Tailwind 4, Vite | statisches Build in `/var/www/perinco_CRM/dist` |
| 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
den Apache-Reverse-Proxy erreichbar.
## Topologie
```mermaid
flowchart LR
U(("Browser")) -->|"HTTPS"| A["Apache :443<br/>pcrm.perinco.de"]
A -->|"/"| D["dist/<br/>SPA, FallbackResource"]
A -->|"/api"| E["Express :5006"]
E --> C[("perinco_CRM")]
E --> S[("perinco_scanner")]
U2(("Browser")) -->|"HTTPS"| A2["Apache :443<br/>scanner.perinco.de"]
A2 -->|"/api, /uploads, /health"| E2["alte Scanner-App :5005"]
E2 --> S2[("project_scanner")]
classDef alt fill:#f7f7f7,stroke:#9a9a9a,stroke-dasharray: 4 3
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.
## Anfrageweg mit Anmeldung
```mermaid
sequenceDiagram
actor B as Browser
participant A as Apache
participant E as Express
participant M as MariaDB
B->>A: POST /api/auth/login
A->>E: weiterleiten
E->>M: SELECT crm_users WHERE username
M-->>E: Zeile mit bcrypt-Hash
E->>E: bcrypt.compare
E-->>B: { token (JWT, 12 h), user }
Note over B: Token im localStorage
B->>A: GET /api/scanner/matches<br/>Authorization: Bearer ...
A->>E: weiterleiten
E->>E: requireAuth: JWT pruefen
E->>M: SELECT crm_users WHERE id (ist der noch aktiv?)
E->>M: SELECT matches JOIN agents, projects
M-->>E: Treffer
E-->>B: JSON
```
Bei `401` loescht das Frontend das Token und springt zurueck 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.
## Rollen
```mermaid
flowchart TD
L["Anmeldung"] --> R{"Rolle"}
R -->|admin| A["Opportunities, Kalender, Kontakte, Scanner<br/>Einstellungen: Nutzerverwaltung, Mein Konto, Scanner, Whisper"]
R -->|user| U["Opportunities, Kalender, Kontakte, Scanner<br/>Einstellungen: Mein Konto, Whisper"]
```
Die Rollenpruefung sitzt **doppelt**: das Menue blendet aus, was nicht erlaubt
ist, und das Backend weist Zugriffe zusaetzlich mit `403` ab (`requireAdmin`).
Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen
fuer Netzwerkdiagramm, Zustaendigkeiten, @-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.
## Belegte Ports auf dem Server
Der Rechner hostet weitere Dienste (n8n, Docker, ~15 Apache-Vhosts). Vor jeder
Portbelegung `ss -tlnp` pruefen. Relevant hier:
| Port | Dienst |
| --- | --- |
| 5006 | perinco CRM Backend (nur `127.0.0.1`) |
| 5005 | alte Scanner-App (`0.0.0.0`) |
| 3000 | weitere Vite-App |
| 5678 | n8n |
+183
View File
@@ -0,0 +1,183 @@
# 02 Datenmodell
Zwei Datenbanken, absichtlich getrennt:
| Datenbank | Inhalt | Zugriff |
| --- | --- | --- |
| `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine | `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.
## perinco_CRM
```mermaid
erDiagram
crm_users ||--o{ crm_leads : "owner_id / created_by"
crm_users ||--o{ crm_opportunities : "owner_id / created_by"
crm_users ||--o{ crm_events : created_by
crm_users ||--o{ crm_event_participants : nimmt_teil
crm_events ||--o{ crm_event_participants : hat
crm_leads ||--o{ crm_lead_connections : from_lead_id
crm_leads ||--o{ crm_opportunity_contacts : ist
crm_opportunities ||--o{ crm_opportunity_contacts : hat
crm_users {
int id PK
varchar username UK
varchar email UK
varchar name
enum role "admin | user"
varchar color "Kalenderfarbe, NULL = aus Id abgeleitet"
longtext avatar "Data-URL"
varchar password_hash "bcrypt"
tinyint is_active
}
crm_leads {
int id PK
varchar first_name
varchar last_name
varchar company
enum status "Neu .. Verloren"
longtext tags "JSON-Array als Text"
longtext avatar
int owner_id FK
int created_by FK
}
crm_lead_connections {
int id PK
int from_lead_id FK
int to_lead_id FK
enum type "Kollege .. Sonstige"
tinyint strength "1 lose, 2 normal, 3 eng"
}
crm_opportunities {
int id PK
varchar title
enum stage "Neu .. Verloren"
tinyint probability "0-100"
decimal daily_rate
decimal duration_days "Personentage, halbe moeglich"
date last_interaction_at
varchar next_step
int owner_id FK
}
crm_opportunity_contacts {
int opportunity_id PK,FK
int lead_id PK,FK
varchar role "z.B. Entscheider"
}
crm_events {
int id PK
varchar title
text description "kann @[Name](lead:12) enthalten"
datetime starts_at
datetime ends_at
tinyint all_day
int created_by FK
}
crm_event_participants {
int event_id PK,FK
int user_id PK,FK
}
```
Drei Eigenheiten, die man kennen muss:
- **`tags` ist Text, kein JSON-Typ.** Bewusst, damit es auf aelteren
MariaDB-Versionen ohne JSON-Funktionen laeuft. 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.
- **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes
Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden
wenige Kilobyte.
## perinco_scanner
```mermaid
erDiagram
agents ||--o{ matches : bewertet_fuer
projects ||--o{ matches : bewertet
portals
agents {
int id PK
varchar name
varchar email "fuer Benachrichtigungen"
longtext cv_text "aus dem PDF extrahiert"
varchar cv_file_path "Pfad im Upload-Verzeichnis"
varchar keywords "kommagetrennt, Suchbegriffe"
text exclude_keywords "kommagetrennt, Ausschluss"
tinyint is_active
}
projects {
int id PK
varchar portal "freelancermap.de, hays.de, ..."
varchar portal_project_id "Id der Boerse"
varchar title
longtext description
varchar location
varchar rate
varchar url
datetime published_at
timestamp created_at "gescrapt am, Aufbewahrung 7 Tage"
}
matches {
int id PK
int agent_id FK
int project_id FK
int match_percentage "0-100, von OpenAI"
text pros "JSON-Array"
text cons "JSON-Array"
varchar status "new | reviewed | applied | rejected"
tinyint notification_sent
}
portals {
int id PK
varchar name UK
varchar username
varchar password_encrypted "AES, SCANNER_ENCRYPTION_KEY"
longtext session_state "Playwright storageState als JSON"
varchar connection_status "connected | expired | error | disconnected"
datetime last_login_attempt
}
settings {
int id PK
varchar key_name UK
text value "alles als Text, auch Zahlen und Schalter"
}
```
- **`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
Treffer verschwinden per `ON DELETE CASCADE` mit.
- **`settings` ist ein Schluessel-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.
## Statuswege
```mermaid
stateDiagram-v2
[*] --> new : Treffer wird angelegt
new --> reviewed : "Als Gesehen markieren"
new --> applied : "Als Beworben markieren"
reviewed --> applied
applied --> new : "Zurueck auf Neu"
reviewed --> new
new --> rejected : "Passt nicht"
reviewed --> rejected
applied --> rejected
rejected --> new : "Zurueck 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
Kennzahlen nicht mit.
+184
View File
@@ -0,0 +1,184 @@
# 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.
## Ein Suchlauf
```mermaid
sequenceDiagram
participant C as node-cron<br/>jede Minute
participant P as Pipeline
participant PW as Playwright
participant B as Projektboersen
participant DB as perinco_scanner
participant AI as OpenAI gpt-4o-mini
participant M as SMTP
C->>P: faellig? (Intervall, Mo-Fr, 07-22 Uhr, Sperrfrist)
P->>P: laeuft schon einer? dann abbrechen
P->>DB: aktive Agenten laden
P->>P: Keywords aller Agenten zusammenfuehren
P->>DB: Portal-Sessions laden
loop je Portal
P->>PW: Chromium starten, Cookies setzen
PW->>B: Suchseite je Keyword
B-->>PW: Projektkarten
PW-->>P: Projekte (Titel, Beschreibung, Ort, Rate, URL)
end
loop je Projekt x Agent
P->>DB: Treffer schon vorhanden? dann weiter
P->>P: Ausschlussbegriff im Text? dann weiter
P->>AI: Lebenslauf vs. Projektbeschreibung
AI-->>P: { matchPercentage, pros[], cons[] }
P->>DB: Treffer speichern
alt Prozent >= Benachrichtigungs-Schwelle
P->>M: E-Mail an Agent oder Standardempfaenger
end
end
```
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.
## Wann laeuft er
```mermaid
flowchart TD
T["Cron-Tick, jede Minute"] --> A{"scanning_active?"}
A -->|nein| STOP1["nichts tun"]
A -->|ja| B{"Intervall geaendert?"}
B -->|ja| RE["neu planen"]
B -->|nein| C{"Sperrfrist nach Start<br/>SCANNER_SCHEDULER_OFFSET"}
C -->|noch aktiv| STOP2["nichts tun"]
C -->|abgelaufen| D{"Mo-Fr?"}
D -->|nein| STOP3["nichts tun"]
D -->|ja| E{"07:00-21:59?"}
E -->|nein| STOP4["nichts tun"]
E -->|ja| F{"Intervall seit<br/>letztem Lauf um?"}
F -->|nein| STOP5["nichts tun"]
F -->|ja| G["Pipeline starten"]
```
Die **Sperrfrist** ist die Antwort auf ein konkretes Problem: beide Instanzen
benutzen dieselben Portal-Cookies. Ohne Versatz ziehen nach einem Neustart beide
gleichzeitig los, was Rate-Limits und CAPTCHAs provoziert - und zwei Chromium
gleichzeitig belasten den Speicher doppelt.
> Fruehere Fassung war fehlerhaft: der Versatz wurde ueber einen vorgealterten
> `lastRunTime` gebaut. Ein Zeitstempel kann einen Lauf aber nur **vorziehen**,
> nie verzoegern - bei Intervall 15 und Versatz 30 lief er deshalb sofort. Jetzt
> ist es eine eigene Frist (`notBefore`).
## Parallelbetrieb - alles verdoppelt
```mermaid
flowchart LR
subgraph alt["alte App (scanner.perinco.de)"]
A1["Express :5005"] --> A2[("project_scanner")]
A1 --> A3["uploads/ in<br/>/var/www/Projekt Scanner/backend"]
A4["ENCRYPTION_KEY"] --- A1
end
subgraph neu["CRM (pcrm.perinco.de)"]
N1["Express :5006<br/>/api/scanner"] --> N2[("perinco_scanner")]
N1 --> N3["uploads/ in<br/>/var/www/perinco_CRM/server"]
N4["SCANNER_ENCRYPTION_KEY"] --- N1
end
B["Projektboersen"]
O["OpenAI"]
A1 --> B
N1 --> B
A1 --> O
N1 --> O
IMP["npm run scanner:import"] -.->|"liest"| A2
IMP -.->|"schreibt"| N2
IMP -.->|"kopiert"| N3
```
| | alte App | CRM |
| --- | --- | --- |
| Datenbank | `project_scanner` | `perinco_scanner` |
| Schluessel | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` |
| CV-Dateien | eigener Ordner | eigener Ordner |
| Aufraeumen | 03:00 | 03:30 |
| Anmeldung | HTTP Basic | CRM-Sitzung (JWT) |
**Der Preis:** Beide bewerten dieselben Projekte unabhaengig 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.
## Uebernahme aus der alten Datenbank
`server/src/scanner/importLegacy.js`, aufrufbar als `npm run scanner:import`:
```mermaid
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"]
P --> PW{"LEGACY_ENCRYPTION_KEY gesetzt?"}
PW -->|ja| PW2["Passwort entschluesseln<br/>und neu verschluesseln"]
PW -->|nein| PW3["Passwort auslassen"]
PW2 --> A["Agenten anlegen, Ids behalten<br/>CV-PDF kopieren"]
PW3 --> A
A --> M{"--with-matches?"}
M -->|ja| M2["Projekte und Treffer<br/>mit Original-Ids"]
M -->|nein| F["fertig"]
M2 --> F
```
Die Ids bleiben erhalten, damit uebernommene 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
> Datenbank lassen sich mit dem dortigen `ENCRYPTION_KEY` nicht mehr
> entschluesseln - der Schluessel 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
> neue Instanz sofort.
## Oberflaeche
| 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 |
| 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
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.
## Endpunkte
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 |
| 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 |
| GET | `/matches` | Filter: `status`, `agentId`, `minPercentage`, `limit` |
| PUT/DELETE | `/matches/:id` | Status setzen, loeschen |
| GET/POST | `/settings` | Geheimnisse maskiert |
| POST | `/scan` | Lauf starten, `409` wenn schon einer laeuft |
| GET | `/status` | laeuft ein Lauf, Scheduler an, Datenbankname |
+85
View File
@@ -0,0 +1,85 @@
# 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**.
Das Backend ist daran nicht beteiligt und sieht keine Audiodaten: kein API-Key,
keine Kosten pro Minute, kein Datenabfluss. Aufnahmen werden nirgends gespeichert.
## Weg der Aufnahme
```mermaid
flowchart LR
MIC(("Mikrofon")) --> MR["MediaRecorder"]
MR --> AC["AudioContext<br/>16 kHz Mono PCM"]
AC --> W["Web Worker"]
W --> ONNX["Whisper als ONNX<br/>WebGPU, sonst WASM"]
ONNX --> T["Text"]
T --> F["Formularfeld"]
HF["Hugging-Face-CDN"] -.->|"einmalig 759 MB"| CACHE[("Cache-Speicher<br/>der Seite")]
CACHE --> ONNX
```
Die Erkennung liegt im Web Worker (`src/lib/whisper.worker.ts`), damit die
Oberflaeche waehrenddessen 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.
## Lebenszyklus des Modells
```mermaid
stateDiagram-v2
[*] --> idle : Anmeldung
idle --> loading : "Sprachmodell laden" oder<br/>automatisch, wenn schon im Cache
loading --> ready : im Worker initialisiert
loading --> error : Abbruch, Haenger, kein Speicher
error --> loading : erneut versuchen
ready --> idle : "Sprachmodell loeschen"
ready --> ready : Diktat benutzen
note right of loading
Phase download: Dateien holen
Phase init: Modell im Worker aufbauen
end note
```
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,
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,
entscheidet die Rolle:
```mermaid
flowchart LR
E["Einstellungen"] --> A["Mein Konto<br/>(alle)"]
E --> W["Whisper<br/>(alle)"]
E --> N["Nutzerverwaltung<br/>(admin)"]
E --> S["Scanner<br/>(admin)"]
```
Ist das Modell noch nicht geladen, wird der Mikrofon-Button zu *Diktat
einrichten* und springt per `goTo('Einstellungen', { tab: 'whisper' })` direkt
dorthin - ein offener Dialog wird vorher geschlossen (`onBeforeNavigate`).
## Warum keine Auswahl
Modell, Genauigkeit, Rechenweg und Sprache stehen als Konstanten in
`src/lib/whisper.tsx`: `large-v3-turbo`, `q4`, Grafikkarte wenn vorhanden,
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.
Eine Auswahl haette 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.
+111
View File
@@ -0,0 +1,111 @@
# 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
sitzt und keine tiefen Links braucht.
## Menue und Ansichten
```mermaid
flowchart TD
SB["Seitenleiste"] --> K["Kategorien"]
K --> O["Opportunities<br/>(Startansicht)"]
K --> C["Kalender"]
K --> L["Kontakte"]
K --> SC["Scanner"]
SC --> SC1["Dashboard"]
SC --> SC2["Unpassend"]
SC --> SC3["Suchagenten"]
SC --> SC4["Boersen-Konten"]
SB --> E["Einstellungen"]
E --> E1["Nutzerverwaltung (admin)"]
E --> E2["Mein Konto"]
E --> E3["Scanner (admin)"]
E --> E4["Whisper"]
```
Der Menuepunkt *Scanner* klappt seine Unterpunkte auf; *Scanner* selbst ist das
Dashboard. Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole,
die Unterpunkte kommen mit den Beschriftungen zurueck.
## Zustand und Datenfluss
```mermaid
flowchart TB
subgraph Kontexte["Kontexte in main.tsx"]
TH["ThemeProvider<br/>hell/dunkel/System"]
TO["ToastProvider"]
AU["AuthProvider<br/>JWT, Benutzer"]
WH["WhisperProvider<br/>Modell, Worker"]
end
APP["App.tsx<br/>currentView, settingsTab"]
NAV["NavigationProvider<br/>goTo(view, {tab})"]
DATA["loadData()<br/>leads, connections, events, users, opportunities"]
Kontexte --> APP
APP --> NAV
APP --> DATA
DATA --> V1["Opportunities"]
DATA --> V2["Kalender"]
DATA --> V3["Kontakte"]
NAV --> DF["DictationField<br/>springt nach Whisper"]
SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"]
```
Die CRM-Daten werden **einmal zentral** geladen und als Props durchgereicht; die
Scanner-Ansichten laden dagegen selbst, weil sie eine andere Datenbank und eine
eigene Auffrischung im 30-Sekunden-Takt haben.
`reload={() => loadData(true)}` laedt still nach - ohne Ladeanzeige, damit ein
Speichern im Dialog nicht die ganze Liste flackern laesst.
## Komponentenkarte
```
src/
App.tsx Layout, Datenladen, Ansichtswechsel
types.ts Gemeinsame Typen, Farbpalette, Rechenhilfen
lib/
api.ts Fetch-Wrapper (JWT, 401-Behandlung) + CRM-Endpunkte
scannerApi.ts Typisierte Endpunkte unter /api/scanner
auth.tsx Anmeldung, Benutzer, Rolle
theme.tsx Hell/Dunkel/System
navigation.tsx Ansichtswechsel fuer tiefe Komponenten
whisper.tsx Modell, Worker-Lebenszyklus
dictation.tsx Aufnahme-Hook
audio.ts Aufnahme zu 16-kHz-Mono-PCM
imageCompress.ts Bilder vor dem Upload verkleinern
components/
Sidebar.tsx Menue inkl. aufklappbarer Scanner-Gruppe
Header.tsx Titel, Untertitel, Dark-Mode-Schalter
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
calendar/ Monat/Woche/Tag, Termine, @-Mentions
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force)
scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten
settings/ Reiter: Nutzerverwaltung, Konto, Scanner, Whisper
whisper/ Einrichtung der Spracherkennung
ui/ Modal, Avatare, Diktatfeld
```
## 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 -
kein `dark:` im Markup.
```mermaid
flowchart LR
T["Umschalter im Header"] --> P["localStorage<br/>perinco_crm_theme"]
P --> H["class 'dark' auf html"]
H --> V["Variablen in index.css<br/>canvas, surface, ink-*, brand-*"]
V --> U["Utility-Klassen der Komponenten"]
SYS["Systemeinstellung"] -.->|"ohne eigene Wahl"| H
```
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).
+134
View File
@@ -0,0 +1,134 @@
# 06 Betrieb
## Deployment
Gebaut wird **lokal**, auf den Server geht nur `dist/` plus Backend-Quellen. Der
Server hat wenig freien RAM und volles Swap - dort wird nicht gebaut.
```mermaid
flowchart LR
L["Arbeitsplatz"] -->|"npm run build"| D["dist/"]
D -->|pscp| S1["/var/www/perinco_CRM/dist"]
L -->|pscp| S2["/var/www/perinco_CRM/server/src"]
L -->|pscp| S3["server/package.json"]
S3 -.->|"nur mit -Deps"| NI["npm install --omit=dev"]
NI --> R["systemctl restart<br/>perinco-crm-backend"]
S2 --> R
R --> H["curl /api/health"]
```
```powershell
npm run build
powershell -File deploy/deploy.ps1 # dist + Backend
powershell -File deploy/deploy.ps1 -Deps # zusaetzlich npm install
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/perinco-crm-backend.service` von Hand kopieren und `daemon-reload`.
## Dienste auf dem Server
| 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 |
Der Verzeichnisname der alten App enthaelt ein **Leerzeichen**. In Skripten und in
der `.env` immer in Anfuehrungszeichen 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
mitten im Scrapen beenden.
## Umgebungsvariablen
`server/.env`, Rechte `600`. Vorlage: `server/.env.example`.
| Variable | Bedeutung |
| --- | --- |
| `PORT`, `HOST` | `5006`, `127.0.0.1` - von aussen nur ueber 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_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` |
Die Scanner-Datenbank wird beim Start automatisch angelegt
(`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale
und Standardeinstellungen werden geseedet.
## 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`.
Fehlt der Browser, funktioniert alles ausser Scan-Lauf und Verbindungstest:
```bash
cd /var/www/perinco_CRM/server && npx playwright install chromium
```
## Erststart des Scanners
```bash
bash /var/www/perinco_CRM/deploy/scanner-setup.sh # .env ergaenzen, Schluessel erzeugen
cd /var/www/perinco_CRM/server
npm run scanner:import -- --dry-run
npm run scanner:import -- --with-matches
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
> 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
| Symptom | Blick darauf |
| --- | --- |
| Anmeldung schlaegt 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) |
| `Target page ... has been closed` | Dienst wurde mitten im Scrapen neu gestartet |
| Backend stirbt beim Scrapen | `MemoryMax` zu klein, `systemctl show ... -p MemoryCurrent` |
| Ton bleibt aus | Schwellenwert, Dashboard-Ansicht offen, einmal auf der Seite geklickt? |
Nuetzliche Abfragen:
```bash
systemctl status perinco-crm-backend
journalctl -u perinco-crm-backend -f
curl -s http://127.0.0.1:5006/api/health
set -a; . /var/www/perinco_CRM/server/.env; set +a
mysql -u"$DB_USER" -p"$DB_PASSWORD" "$SCANNER_DB_NAME" -e \
"SELECT COUNT(*) FROM matches; SELECT key_name, value FROM settings"
```
## Sicherung
Beide Datenbanken einzeln, dazu die CV-Dateien:
```bash
set -a; . /var/www/perinco_CRM/server/.env; set +a
mysqldump -u"$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" > crm_$(date +%F).sql
mysqldump -u"$DB_USER" -p"$DB_PASSWORD" "$SCANNER_DB_NAME" > scanner_$(date +%F).sql
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
alten App passiert.
+59
View File
@@ -0,0 +1,59 @@
# Dokumentation perinco CRM
Technische Dokumentation mit Funktionszeichnungen. Die Diagramme sind Mermaid und
werden von Gitea direkt gerendert.
| 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 |
| [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 |
Die Anwendungsdokumentation - Bedienung, Designentscheidungen, Messwerte zur
Spracherkennung - steht im [README des Projekts](../README.md).
## Das Ganze in einem Bild
```mermaid
flowchart TB
subgraph Browser
SPA["React-SPA<br/>Vite-Build"]
WSP["Whisper im Web Worker<br/>WebGPU / WASM"]
end
subgraph Server["Server 212.227.135.41"]
AP["Apache<br/>:443"]
API["Express-Backend<br/>127.0.0.1:5006"]
SCH["Scanner-Scheduler<br/>node-cron"]
PW["Playwright<br/>Chromium"]
DB1[("MariaDB<br/>perinco_CRM")]
DB2[("MariaDB<br/>perinco_scanner")]
ALT["alte Scanner-App<br/>:5005"]
DB3[("MariaDB<br/>project_scanner")]
end
OAI["OpenAI<br/>gpt-4o-mini"]
BOERSE["Projektboersen<br/>freelancermap, hays, ..."]
SPA -->|"HTTPS /api"| AP
AP --> API
API --> DB1
API --> DB2
SCH --> PW
PW --> BOERSE
SCH --> OAI
SCH --> DB2
API -.-> SCH
ALT --> DB3
ALT --> BOERSE
SPA -.->|"Audio bleibt im Browser"| WSP
classDef alt fill:#f7f7f7,stroke:#9a9a9a,stroke-dasharray: 4 3
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.