Files
perinco-CRM/docs/09-quellcode.md
T
Sascha WolfandClaude Opus 5 64dea0b100 feat(opportunities): gewichtetes Volumen entfernt
So wird bei perinco nicht gerechnet: die Summe "Gewichtet" aenderte sich beim
Schieben der Abschlusswahrscheinlichkeit und behauptete damit eine Groesse, die
es im Haus nicht gibt.

Weg sind die Kennzahl ueber der Liste, die Spalte samt Sortierung, die zweite
Zahl in der Vorschau des Formulars und weightedVolume() selbst. Die
Wahrscheinlichkeit bleibt an der Opportunity - Schieberegler im Formular, Prozent
in Liste und Pipeline-Karte; sie geht nur in keine Summe mehr ein.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:17:53 +02:00

20 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 migrateLeadKind(), migrateMailPool(), seedAcquisitionOptions(), dropProviderOptions(), ensureAdminUser()

dateStrings: true ist eine bewusste Entscheidung: Termine kommen als YYYY-MM-DD HH:MM:SS ohne Zeitzonenumrechnung an. Die Terminansicht rechnet mit Wandzeit; ein Date-Objekt aus dem Treiber würde beim Serialisieren nach UTC verschieben.

Die beiden Einmal-Umstellungen laufen bei jedem Start, tun aber nur beim ersten Mal etwas:

  • migrateLeadKind() macht aus dem Vertriebsstatus eines Kontakts seine Art. Erkannt wird der Altstand an der Spalte crm_leads.status selbst - liegt schon kind vor, passiert nichts. Die Auswahl wird erst auf alte plus neue Werte erweitert, dann werden alle Altwerte auf Recruiter gesetzt, dann wird umbenannt und auf die drei neuen Werte verengt. Direkt zu verengen würde jeden Altwert zu '' machen.
  • migrateMailPool() schiebt die vom Postfachabgleich angelegten Kontakte in den Vorbereich. Sie merkt sich das per Schalter in crm_settings, weil an den Daten selbst nicht zu erkennen ist, ob sie schon gelaufen ist.
  • seedAcquisitionOptions() legt den Startbestand der Akquise-Dropdowns an: die Kanäle, die vorher im Quelltext standen, plus alles, was in vorhandenen Einträgen als Freitext steht. Auch hier ein Schalter in crm_settings - ohne ihn wäre jeder Neustart ein Wiederherstellen von Werten, die jemand bewusst entfernt hat.
  • dropProviderOptions() nimmt Anbieter wieder aus den Auswahllisten heraus - das Feld ist Freitext mit Vorschlägen. Erkannt am Spaltentyp: steht provider noch im ENUM, ist die Umstellung offen. Die Zeilen müssen vor dem Verengen weg, sonst weist MariaDB den ALTER ab.

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 /?trash=true, POST /, PATCH /:id, DELETE /:id (Papierkorb), POST /:id/restore, POST /trash/empty ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete
acquisitions.js GET /?view=eingang|archiv|alle, POST /, PATCH /:id, DELETE /:id, POST /:id/opportunity; Auswahllisten: GET/POST /options, PATCH/DELETE /options/:id Einträge ändern/löschen nur Ersteller oder Admin; Auswahllisten jeder Angemeldete
badges.js GET / Zahlen für die Seitenleiste: Akquise-Archiv, beide Papierkörbe, verworfene Scanner-Treffer. Der Scanner-Zähler ist null, wenn dessen Datenbank fehlt
acquisitionRules.js (kein Router) Fristen und die SQL-Bedingungen IS_OPEN, IN_ARCHIVE, NOT_DUE, OPPORTUNITY_GONE. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen
dashboard.js GET /?from=&to=&bucket=tag|monat|quartal|jahr drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise und crm_acquisition_history; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin
mail.js GET /status, /messages, /messages/:id, /candidates; POST /messages/:id/links, /messages/:id/restore, /candidates/:id/adopt, /candidates/:id/reject, /trash/empty; DELETE /links/:id, /messages/:id; requireAdmin: GET/POST /settings, POST /test, POST /sync lesen, zuordnen und löschen 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 Termine unter Interviews. 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/opportunities/OpportunityTrash.tsx Papierkorb der Opportunities: wiederherstellen, leeren, Restlaufzeit je Eintrag
components/opportunities/opportunityUi.tsx STAGE_LABELS (Anzeige, mit Umlaut), STAGE_HINTS (Erläuterung als Titelhinweis), STAGE_STYLES, STAGE_COLORS, StageBadge
components/dashboard/Dashboard.tsx Startansicht: Zeitraumwahl, vier Kennzahlen, Verlauf, Berater, Termine, „Beim Kunden". buildAxis() erzeugt auch leere Abschnitte
components/dashboard/dashboardUi.tsx useCountUp() (Zahl läuft hoch, respektiert prefers-reduced-motion), StatCard, SeriesChart, BarList, Panel - von Hand statt Diagrammbibliothek
components/acquisition/Acquisitions.tsx Akquise und Archiv (view); Suche, Sortierung, Übernahme, Löschen
components/acquisition/AcquisitionForm.tsx Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag
components/acquisition/AcquisitionTable.tsx die Liste; safeLink() lässt nur http(s) zum Link werden
components/acquisition/acquisitionUi.tsx fieldClass (abgesetzte Felder) und OptionSelect - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; alwaysOffer hält den eigenen Namen für Von immer wählbar. Anbieter nutzt es nicht: Freitext mit <datalist>
components/settings/AcquisitionSettings.tsx Reiter Einstellungen → Akquise: die vier Auswahllisten pflegen
components/leads/leadUi.tsx inputClass, labelClass, KindBadge, Avatar, Farben der Kontaktart
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/leads/MailingListDialog.tsx Adressen der Kontakte mit Einverständnis: Zwischenablage und mailto: mit BCC, Längengrenze 1900 Zeichen
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 und ConfirmDialog; useDismiss() schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel openModals), der Hintergrundklick muss dort beginnen und enden
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, MAIL_VIEWS, ACQUISITION_VIEWS, OPPORTUNITY_VIEWS, CONTACT_VIEWS Ansichten, Reiter und aufklappbare Gruppen
VIEW_TITLES, viewTitle() abweichende Beschriftung, wo der Ansichtsname nur intern eindeutig sein muss (Opportunity-Papierkorb → Papierkorb)
MAIL_LEAD_SOURCE, LeadOriginFilter Herkunft „Mail" in Lead.source und ihr Filter
USER_COLORS, userColor() Farbe gesetzt, sonst stabil aus der Benutzer-Id
opportunityVolume(), formatEuro() Festpreis, sonst Tagessatz x Personentage; formatiert. Ein gewichtetes Volumen gibt es bewusst nicht
OPPORTUNITY_STAGES, OPEN_STAGES, LEAD_KINDS, CONNECTION_TYPES die Aufzählungen der Datenbank
ACQUISITION_OPTION_FIELDS, ACQUISITION_FIELD_LABELS, ACQUISITION_FIELD_HINTS die vier Auswahllisten der Akquise samt Beschriftung
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.