Drei Dinge, die im Alltag gefehlt haben: - Projektdaten kennen jetzt einen Projektpreis. Ist er gesetzt, gilt er als Volumen statt Tagessatz x Laufzeit - Festpreisprojekte mussten bisher ausgerechnet und in den Tagessatz gefaelscht werden. - Dialoge schliessen auch per Klick in den Hintergrund, nicht nur ueber das X. Escape trifft dabei nur den obersten Dialog; eine Textmarkierung, die im Dialog beginnt und daneben endet, schliesst nichts. - Fehlt ein Ansprechpartner noch im CRM, oeffnet "Neuer Kontakt..." in der Opportunity denselben Dialog wie unter Kontakte. Der frische Kontakt haengt danach sofort als Ansprechpartner dran, ohne die Opportunity zu verlassen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
297 lines
16 KiB
Markdown
297 lines
16 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 **Beginn** des Laufs landet im `finally` in `settings.last_scan_at` - im
|
|
`finally`, damit sich ein gescheiterter Lauf nicht sofort wiederholt, aber mit
|
|
dem Startzeitpunkt, weil das Intervall ein Takt ist und keine Pause zwischen
|
|
zwei Läufen. Mit dem Endzeitpunkt wurden aus 15 Minuten 19.
|
|
* `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/leads/MailingListDialog.tsx` | Adressen der Kontakte mit Einverständnis: Zwischenablage und `mailto:` mit BCC, Längengrenze 1900 Zeichen |
|
|
| `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` und `ConfirmDialog`; `useDismiss()` schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel `openModals`), der Hintergrundklick muss dort beginnen **und** enden |
|
|
| `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()` | Festpreis, sonst 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.
|