Files
perinco-CRM/docs/09-quellcode.md
T
Sascha WolfandClaude Opus 5 6fd713497d feat(postfach): crm@perinco.de lesend anbinden, Kontakte aus Mails
Spiegelt das Vertriebspostfach ueber Microsoft Graph ins CRM. Aus jeder Mail
entstehen Kontakt-Vorschlaege, und jede Mail laesst sich an Opportunities und
Kontakte haengen.

Uebernommen ist die Idee aus dem Altsystem KeepTrack, nicht die Umsetzung:

- Delta Query statt "$filter=isRead eq false" mit anschliessendem PATCH. Damit
  genuegt die Anwendungsberechtigung Mail.Read, es braucht keinen Schreibzugriff
  auf das Postfach, und eine vorher in Outlook geoeffnete Mail geht nicht
  verloren. Der Fortschritt liegt als delta_link in der Datenbank und ueberlebt
  Neustarts; ein Lauf ist gedeckelt und fortsetzbar.
- Tenant- und Client-ID kommen aus der .env, nicht aus dem Browser. Im
  Altsystem konnte jeder Angemeldete das Server-Secret gegen einen fremden
  Tenant verwenden.
- Kontaktdaten werden aus den Graph-Feldern und der Signatur gelesen, ohne
  Sprachmodell. Mailinhalte verlassen den Server nicht.
- Ein Vorschlag ohne E-Mail-Adresse wird nicht mit einer erfundenen Adresse
  angelegt, sondern bleibt zur Durchsicht liegen. Ist die Adresse schon als
  Kontakt vorhanden, werden nur leere Felder ergaenzt.
- Mails haengen als eigene Verknuepfung an Opportunity und Kontakt, nicht als
  Text im Notizfeld - loesbar und mehrfach moeglich.
- Fremdes Mail-HTML wird serverseitig entschaerft und in einem iframe mit
  sandbox="" gezeigt. Externe Bilder sind per CSP gesperrt, damit das Oeffnen
  dem Absender nichts meldet.
- Der Verbindungstest prueft Anmeldung und tatsaechlichen Postfachzugriff und
  benennt den wahrscheinlichen Grund. Der alte Test holte nur ein Token.

Fehlen die MS_*-Werte, startet das CRM normal; die Einstellungen nennen die
fehlende Variable. Neue Tabellen: crm_mail_messages, crm_mail_links,
crm_mail_contact_candidates, crm_mail_sync_state, crm_settings.

Uebernommene Kontakte tragen source = 'Mail'; die Kontaktliste hat dafuer einen
Herkunftsfilter.

Nicht enthalten: Senden, Antworten, Weiterleiten, Anhaenge und die automatische
Zuordnung zu Opportunities.

npm test im Verzeichnis server prueft Kontaktextraktion und Graph-Client
(47 Pruefungen, ohne Framework und ohne Datenbank).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:06:01 +02:00

15 KiB

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:

flowchart TD
    J["express.json({ limit: '6mb' })<br/>Avatare kommen als Data-URL"] --> C["cors (nur fuer 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/>faengt alles Unbekannte"]
    F --> E["Fehlerbehandlung:<br/>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
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.

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()
contacts.js adoptCandidate(): anlegen oder vorhandenen Kontakt 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 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<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 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.

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.

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/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 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
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 Aufzaehlungen der Datenbank
eventColor(), leadFullName(), leadInitials(), contactFullName() Anzeigelogik

Datenfluss im Frontend

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"] -->|"blaettert, sucht, filtert"| J["/api/mail"]
    D --> I
    I -->|"nach Uebernahme eines Vorschlags"| F

Die Mailansicht laedt ihre Mails selbst, bekommt Kontakte und Opportunities aber als Props - der Zuordnungsdialog braucht beide Listen zum Vorschlagen. Nach einer Kontaktuebernahme ruft sie reload(), damit der neue Kontakt in der Kontaktliste der App auftaucht.

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.