# 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` | 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 | 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/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 | 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. ### 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 })` | erspart es, Callbacks durch vier Ebenen zu reichen | | `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/scanner/scannerUi.tsx` | `cardClass`, Buttonklassen, `MatchBadge`, `MatchStatusBadge`, `PortalStatusPill`, `formatDateTime`, `projectTimestamp` | | `components/ui/Modal.tsx` | `Modal` (Escape schliesst) 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 | | `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 | | `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"] ``` Die fuenf CRM-Endpunkte laufen **parallel**, nicht nacheinander - sonst addieren sich fuenf Rundreisen beim Anmelden. Die Scanner-Ansichten haengen bewusst nicht an `loadData`: andere Datenbank, andere Auffrischung.