# 09 Quellcode-Referenz Was in welcher Datei steckt und welche Zusagen sie einhaelt. 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' })
Avatare kommen als Data-URL"] --> C["cors (nur fuer den Dev-Server)"] C --> H["/api/health"] H --> R["/api/auth, /users, /leads,
/connections, /events, /opportunities"] R --> S["/api/scanner (requireAuth + Router)"] S --> F["/api → 404 JSON
faengt alles Unbekannte"] F --> E["Fehlerbehandlung:
400 kaputtes JSON, 413 zu gross, 500 Rest"] ``` Beim Start: `initDatabase()` (CRM-Schema, Admin-Seed) → `initScannerDatabase()` (darf fehlschlagen, dann laeuft das CRM ohne Scanner) → Scheduler, sofern `SCANNER_SCHEDULER !== 'false'`. ### `server/src/db.js` | Export | Zweck | | --- | --- | | `pool` | mysql2-Pool, `connectionLimit: 8`, `dateStrings: true` | | `initDatabase()` | fuehrt `schema.sql` Statement fuer 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 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 pruefen **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 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. ### 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` fuer 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 | | `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 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. * Der letzte aktive Administrator kann nicht degradiert, deaktiviert oder geloescht 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 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 ergaenzen; `markCandidate()` | | `sync.js` | ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung | | `scheduler.js` | Minutentakt, Faelligkeit 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. * `extractCandidates()` verlangt im Fliesstext eine Anrede vor dem Namen. Ohne diese Bedingung liest der Musterabgleich jede Folge grossgeschriebener Woerter als Namen. * Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf kommt sofort mit `skipped` zurueck, statt parallel zu laufen. ### `server/src/routes/scanner.js` Die portierte Scanner-API unter `/api/scanner`, vollstaendig 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). * `SECRET_KEYS` (`openai_api_key`, `smtp_pass`) werden als `********` ausgeliefert; kommt dieser Wert oder ein leeres Feld zurueck, 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 geprueft | | `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 | | `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/pdfParser.js` | `pdf-parse`, doppelte Leerzeilen raus | | `utils/crypto.js` | AES ueber `crypto-js`; `decryptWith()` fuer fremde Schluessel | | `uploadDir.js` | `UPLOAD_DIR`, `ensureUploadDir()`, `resolveCvPath()` | | `importLegacy.js` | einmalige Uebernahme aus der alten Datenbank | Zusagen, auf die sich der Rest verlaesst: * `runMatchingPipeline()` laeuft **nie zweimal gleichzeitig** und gibt `false` zurueck, wenn schon einer laeuft. `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(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 | | `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 | 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 moeglich. Nur `ScannerProject` traegt beide Schreibweisen fuer `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()` haengt 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 Datenbank, anders als die Kalendertermine. `formatMailDate()` zeigt heute nur die Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr. ### Kontexte | Datei | Haelt | 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 | ### 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 | | `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, Uebernehmen, Sammeluebernahme | | `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergaenzen 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 `