Files
perinco-CRM/docs/09-quellcode.md
T
Sascha WolfandClaude Opus 5 9a4266ae7e fix(scanner): Suchintervall ist ein Takt, keine Pause
`last_scan_at` hielt das ENDE des Laufs fest. Der Scheduler rechnet die
Fälligkeit aber von diesem Wert aus, also kam die Laufzeit von drei bis
vier Minuten jedes Mal obendrauf: bei Einstellung 15 Minuten lagen die
Läufe 19 Minuten auseinander.

Gemessen heute früh, unsere Instanz gegen das Original:

    wir:      07:00  07:19  07:38  07:57  08:16   → 19 Min
    Original: 07:00  07:16  07:31  07:47  08:03   → 15-16 Min

Über das Fenster 07-22 Uhr sind das rund 47 statt 58 Durchgänge pro Tag.
Die Trefferliste der Börsen zeigt nur die neuesten Projekte, jeder
ausgelassene Durchgang kostet deshalb echte Funde.

Jetzt wird der Startzeitpunkt gespeichert. Das `finally` bleibt, damit
sich ein gescheiterter Lauf nicht sofort wiederholt; dauert ein Lauf
länger als das Intervall, ist der nächste sofort fällig - die Sperre aus
demselben Commit verhindert die Überlappung.

Kein Rückschritt zum vorherigen Fehler (e58b94f): der Zeitstempel liegt
weiterhin in der Datenbank und trägt den Zeitplan über Neustarts.

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

16 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 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.

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 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.

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.