893 Stellen in Fliesstext, Tabellen und Mermaid-Beschriftungen. Dazu die sichtbaren ss-Fälle (ausschließlich, groß, außen, schließt, heißt, draußen, weiße, anstoßen, Straße). Nicht angetastet, damit die Doku weiter beschreibt, was wirklich in der Datenbank steht: - Codeblöcke und Code-Spans - dort stehen Bezeichner, SQL, Enum-Werte und Dateinamen: `mail_pool`, `'uebernommen'`, `extract.js` - in Mermaid nur der Text in Anführungszeichen; Knotennamen, Pfeile und Werte wie source = 'Mail' bleiben, wie sie sind - `empfaenger` im ER-Diagramm von crm_mail_contact_candidates: die Aufzählung dort nennt die Datenbankwerte, nicht die Anzeige Die Menü-Skizze in der Benutzeranleitung zeigt jetzt die aufgeklappten Kontakte mit "Aus Mails" samt Zähler. 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 einhält. 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 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 werdenNULL, 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 /loginantwortet 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.
| 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
401verwirft das Token genau einmal und wiederholt; so überlebt ein Lauf ein serverseitig zurückgezogenes Token. upsertMessage()lässt die Textspalten unberührt, wennbodyTextim übergebenen Objektundefinedist. 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 (
runninginsync.js) - ein zweiter Aufruf kommt sofort mitskippedzurück, statt parallel zu laufen.
server/src/routes/scanner.js
Die portierte Scanner-API unter /api/scanner, vollständig 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 zählt der Dateiname.GET /agentslässtcvTextweg (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 gibtfalsezurück, wenn schon einer läuft.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 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.
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/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 schließt) 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 Aufzählungen 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"] -->|"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.