# 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' })
Avatare kommen als Data-URL"] --> C["cors (nur für 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
fängt alles Unbekannte"] F --> E["Fehlerbehandlung:
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(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 `