Files
perinco-CRM/docs/09-quellcode.md
T
Sascha WolfandClaude Opus 5 7973dc07b1 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>
2026-08-11 09:45:58 +02:00

294 lines
15 KiB
Markdown

# 09 Quellcode-Referenz
Was in welcher Datei steckt und welche Zusagen sie einhält. Gedacht zum
Nachschlagen, nicht zum Durchlesen.
## Backend
### `server/src/server.js`
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 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/>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 läuft das CRM ohne Scanner) → Scheduler, sofern
`SCANNER_SCHEDULER !== 'false'`.
### `server/src/db.js`
| Export | Zweck |
| --- | --- |
| `pool` | mysql2-Pool, `connectionLimit: 8`, `dateStrings: true` |
| `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 würde beim Serialisieren nach UTC
verschieben.
`ensureAdminUser()` legt nur an, wenn `crm_users` **leer** ist. Ohne
`ADMIN_PASSWORD` erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log -
im Quelltext steht kein Passwort.
### `server/src/middleware/auth.js`
| Export | Zweck |
| --- | --- |
| `signToken(user)` | JWT mit `sub` und `role`, Laufzeit aus `JWT_EXPIRES_IN` (12 h) |
| `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 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 längenbegrenzt.
### Die CRM-Router
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` 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; ä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 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 über die API keine Benutzernamen aufzählen lassen.
* Der letzte aktive Administrator kann nicht degradiert, deaktiviert oder
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 Schlüssel - ein
unbekannter Name kommt so nie in die Datenbank.
Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst
**ohne Geheimnisse**: Zugangsdaten stehen in `server/.env`.
### `server/src/mail/`
Postfachanbindung, fachlich in [11 Postfachanbindung](11-postfach.md).
| Datei | Inhalt |
| --- | --- |
| `config.js` | `.env` lesen, `MAIL_CONFIGURED`, `missingCredentials()`, verstellbare Einstellungen mit Vorgaben (`mailSettings()`) |
| `graph.js` | Token mit Zwischenspeicher, `graphRequest()` mit Wiederholung bei 429/5xx und Beachtung von `Retry-After`, `testConnection()` |
| `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 ergänzen; `markCandidate()` |
| `sync.js` | ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung |
| `scheduler.js` | Minutentakt, Fälligkeit aus `last_run_at`, kein Zeitfenster |
Eigenheiten, die man kennen muss:
* `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 großgeschriebener
Wörter als Namen.
* Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
kommt sofort mit `skipped` zurück, statt parallel zu laufen.
### `server/src/routes/scanner.js`
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 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 zurück, bleibt der
gespeicherte Wert stehen.
* `normalizeCookie()` biegt Exporte von Cookie-Editor auf das zurecht, was
Playwright akzeptiert (`sameSite`, `expirationDate` → `expires`).
### `server/src/scanner/`
| Datei | Inhalt |
| --- | --- |
| `db/config.js` | Sequelize-Instanz, eigene Datenbank, `underscored: true` |
| `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` | 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 älter als 7 Tage löschen (Treffer kaskadieren) |
| `services/pdfParser.js` | `pdf-parse`, doppelte Leerzeilen raus |
| `utils/crypto.js` | AES über `crypto-js`; `decryptWith()` für fremde Schlüssel |
| `uploadDir.js` | `UPLOAD_DIR`, `ensureUploadDir()`, `resolveCvPath()` |
| `importLegacy.js` | einmalige Übernahme aus der alten Datenbank |
Zusagen, auf die sich der Rest verlässt:
* `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:
`Project` benennt die Spalte um, und in `where` greift die Zuordnung nicht.
## Frontend
### `src/lib/api.ts`
| Export | Zweck |
| --- | --- |
| `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` 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 übernehmen |
Zwei Feinheiten: Der `Content-Type` wird **nicht** gesetzt, wenn der Body
`FormData` ist. Und ein Netzwerkfehler wird zu `ApiError(status 0)` mit
„Server nicht erreichbar" - das Frontend muss `fetch`-Ausnahmen nicht kennen.
### `src/lib/scannerApi.ts`
Typen (`ScannerAgent`, `ScannerPortal`, `ScannerProject`, `ScannerMatch`,
`ScannerSettings`, `ScannerStatus`), das Objekt `scannerApi` mit allen Aufrufen,
`SCANNER_SETTINGS_DEFAULTS`, `SECRET_MASK` und `openAgentCv()`.
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 möglich. Nur `ScannerProject`
trägt beide Schreibweisen für `created_at`, weil das Modell die Spalte selbst
umbenennt.
### `src/lib/mailApi.ts`
Typen (`MailMessageSummary`, `MailMessageDetail`, `MailCandidate`, `MailLink`,
`MailStatus`, `MailSyncResult`, `MailConnectionTest`, `MailSettingsValues`), das
Objekt `mailApi`, `MAIL_SETTINGS_DEFAULTS` und die Datumshelfer.
`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
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 | Hält | Eigenheit |
| --- | --- | --- |
| `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, 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 |
Warum genau diese Konstanten festgelegt sind, steht in
[04 Diktat](04-diktat.md#warum-keine-auswahl).
### Gemeinsame Bausteine
| Datei | Inhalt |
| --- | --- |
| `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, Ü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 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 |
### `src/types.ts`
Neben den Interfaces stehen hier die Rechenregeln, damit Frontend und Anzeige
nicht auseinanderlaufen:
| Export | Zweck |
| --- | --- |
| `ViewState`, `SettingsTab`, `ADMIN_SETTINGS_TABS`, `SCANNER_VIEWS` | Ansichten und Reiter |
| `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 Aufzählungen der Datenbank |
| `eventColor()`, `leadFullName()`, `leadInitials()`, `contactFullName()` | Anzeigelogik |
## Datenfluss im Frontend
```mermaid
flowchart TD
A["App.tsx: loadData()"] -->|"Promise.all"| B["5 Endpunkte parallel"]
B --> C["leads, connections, events, users, opportunities"]
C --> D["als Props in die Ansichten"]
D --> E["Dialog speichert"]
E --> F["reload() = loadData(true)"]
F -->|"still, ohne Ladeanzeige"| C
G["Scanner-Ansichten"] -->|"eigener 30-s-Takt"| H["/api/scanner"]
I["Mailansicht"] -->|"blättert, sucht, filtert"| J["/api/mail"]
D --> I
I -->|"nach Übernahme eines Vorschlags"| F
```
Die Mailansicht lädt ihre Mails selbst, bekommt Kontakte und Opportunities aber
als Props - der Zuordnungsdialog braucht beide Listen zum Vorschlagen. Nach einer
Kontaktübernahme ruft sie `reload()`, damit der neue Kontakt in der Kontaktliste
der App auftaucht.
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.