Files
perinco-CRM/docs/09-quellcode.md
T
Sascha WolfandClaude Opus 5 4d90f14d6b docs: Anleitungen fuer Benutzer und Entwickler, Quellcode-Referenz, Glossar
Die bisherige Dokumentation beschrieb die Architektur, half aber niemandem, der
die Anwendung zum ersten Mal oeffnet. Dazu jetzt vier Kapitel:

- 07 Anleitung fuer Benutzer: was die Anwendung tut, jeder Bereich Schritt fuer
  Schritt, warum der Signalton nur im Dashboard klingelt, was Loeschen eines
  Suchagenten mitnimmt
- 08 Anleitung fuer Entwickler: einrichten, pruefen, Feld/Menuepunkt/Reiter/
  Endpunkt hinzufuegen, dazu eine Tabelle der Fallen, die real Zeit gekostet
  haben (Routen-Reihenfolge, FormData, Leerzeichen in Pfaden, Zeitstempel
  koennen nur vorziehen, Kaskaden beim Loeschen)
- 09 Quellcode-Referenz: jede Datei mit ihren Exporten und den Zusagen, auf die
  sich der Rest verlaesst
- 10 Glossar: Agent, Match, Lead, Opportunity und die Statuswerte der Datenbank

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

11 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

Wiederkehrende Muster in diesen Routern:

  • emptyToNull() - leere Zeichenketten werden NULL, 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 /login antwortet 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/routes/scanner.js

Die portierte Scanner-API unter /api/scanner, vollstaendig 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 zaehlt der Dateiname.
  • GET /agents laesst cvText weg (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 gibt false zurueck, wenn schon einer laeuft. isPipelineRunning() fragt den Zustand ab.
  • Der Zeitpunkt landet im finally in settings.last_scan_at - auch nach einem Abbruch, damit sich ein gescheiterter Lauf nicht sofort wiederholt.
  • 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 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.

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 }) erspart es, Callbacks durch vier Ebenen zu reichen
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/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
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"]

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.