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>
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 werdenNULL, 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 /loginantwortet 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
401verwirft das Token genau einmal und wiederholt; so ueberlebt ein Lauf ein serverseitig zurueckgezogenes Token. upsertMessage()laesst die Textspalten unberuehrt, wennbodyTextim uebergebenen Objektundefinedist. 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 (
runninginsync.js) - ein zweiter Aufruf kommt sofort mitskippedzurueck, statt parallel zu laufen.
server/src/routes/scanner.js
Die portierte Scanner-API unter /api/scanner, vollstaendig in
03 Projekt Scanner tabelliert. Eigenheiten:
multerschreibt CV-PDFs nachUPLOAD_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 /agentslaesstcvTextweg (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 gibtfalsezurueck, wenn schon einer laeuft.isPipelineRunning()fragt den Zustand ab.- Der Zeitpunkt landet im
finallyinsettings.last_scan_at- auch nach einem Abbruch, damit sich ein gescheiterter Lauf nicht sofort wiederholt. cleanup.jsbenutztsequelize.col('created_at')statt des Attributnamens:Projectbenennt die Spalte um, und inwheregreift 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.