Der Postfachabgleich hat gefundene Personen bisher direkt in den Kontaktbestand geschrieben. Damit wächst eine gepflegte Liste ungefragt mit jeder Adresse aus jeder Mail - genau daran ist das Altsystem gescheitert. Funde landen jetzt in einem Vorbereich (crm_leads.mail_pool = 1), der als Unterpunkt "Aus Mails" unter dem Menüpunkt Kontakte aufklappt. Der Kontaktbestand bleibt unberührt, bis dort jemand entscheidet; in Opportunities, Kalender und Netzwerk tauchen die Funde nicht auf. Eine Spalte statt einer zweiten Tabelle, weil crm_mail_links und crm_mail_contact_candidates per Fremdschlüssel auf crm_leads zeigen - so behält der Vorbereich seine Mailbezüge. Übernehmen kennt zwei Wege: - neue Person: Kopie mit mail_pool = 0, die Mailverknüpfungen wandern mit - Dublette (Adresse, sonst voller Name): gold markiert, abweichende Felder mit altem und neuem Wert; die Feldauswahl schreibt sie in den vorhandenen Kontakt. Vorausgewählt sind nur Lücken - ein Wert aus einer alten Signatur soll nichts Gepflegtes stillschweigend ersetzen. db.js verschiebt beim ersten Start die vorhandenen Kontakte mit source = 'Mail' in den Vorbereich, einmalig über den Merker leads_mail_pool_migrated in crm_settings. Die Sidebar-Gruppe ist dabei generisch geworden (MenuGroup) - Kontakte und Scanner benutzen jetzt denselben Code. 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; 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; 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(), findLeadByEmail() je Bestand |
contacts.js |
adoptCandidate(): Eintrag in „Aus Mails" (target: 'pool') oder richtiger Kontakt ('contacts'), sonst 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 |
leadsApi.mailPoolSources / .promote / .promoteMany |
Vorbereich „Aus Mails": Herkunft lesen, einzeln bzw. gesammelt uebernehmen |
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/leads/mailPool.ts |
findMailPoolMatch(), diffFields(), isConflict(), MAIL_POOL_FIELDS - Dublettenabgleich des Vorbereichs |
components/leads/MailPoolLeads.tsx |
Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Uebernehmen, Sammeluebernahme |
components/leads/MailPoolMergeDialog.tsx |
Feldauswahl beim Ergaenzen 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 <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.